@arcforgelabs/flow 2026.10.49 → 2026.10.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,9 +25,10 @@ import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
25
25
  // person must look), Ready (checks pass), then the accounting system's
26
26
  // stages: Draft (a draft there, or on its way), Approved, Sent (to the
27
27
  // customer). With no accounting system, an issued invoice is Approved.
28
- const TABS = [['pending', 'Pending'], ['ready', 'Ready'], ['draft', 'Draft'], ['approved', 'Approved'], ['sent', 'Sent'], ['void', 'Void']];
28
+ // Non-chargeable: work a lab's rule keeps in Flow and never bills.
29
+ const TABS = [['pending', 'Pending'], ['ready', 'Ready'], ['draft', 'Draft'], ['approved', 'Approved'], ['sent', 'Sent'], ['non_chargeable', 'Non-chargeable'], ['void', 'Void']];
29
30
  const TAB_LABEL = Object.fromEntries(TABS);
30
- const TAB_TONE = { pending: 'danger', ready: 'success', draft: 'info', approved: 'info', sent: 'success', void: 'mute' };
31
+ const TAB_TONE = { pending: 'danger', ready: 'success', draft: 'info', approved: 'info', sent: 'success', non_chargeable: 'mute', void: 'mute' };
31
32
  // Tab names an earlier Flow kept in this browser.
32
33
  const OLD_TAB = { problem: 'pending', to_check: 'ready', issued: 'approved' };
33
34
  const NUMBERED = ['draft', 'approved', 'sent', 'void'];
@@ -359,7 +360,8 @@ export async function renderInvoiceList(main, ui) {
359
360
  run: () => voidAll(voidable),
360
361
  });
361
362
  }
362
- // A draft that was never issued is deleted on Pending and Ready. An issued
363
+ // A draft that was never issued is deleted on Pending, Ready and
364
+ // Non-chargeable. An issued
363
365
  // invoice is deleted only while Flow's copy of Xero says Draft. Approved
364
366
  // or paid stays hidden. A mix runs every ticked row and reports each one.
365
367
  const fresh = rows.filter((i) => i.status === 'draft' && i.issuedVersion === 0);
@@ -367,7 +369,7 @@ export async function renderInvoiceList(main, ui) {
367
369
  const xeroDraft = issuedXero.filter((i) => xeroById.get(i.id)?.state === 'draft');
368
370
  const xeroApproved = issuedXero.filter((i) => ['approved', 'part_paid', 'paid'].includes(xeroById.get(i.id)?.state));
369
371
  const deletable = [...fresh, ...xeroDraft, ...xeroApproved];
370
- const onDraftTabs = ['pending', 'ready'].includes(state.status) && fresh.length;
372
+ const onDraftTabs = ['pending', 'ready', 'non_chargeable'].includes(state.status) && fresh.length;
371
373
  if (context.canIssue && (onDraftTabs || xeroDraft.length)) {
372
374
  danger.push({
373
375
  icon: 'trash-2', label: 'Delete', iconOnly: true, tone: 'danger', title: 'Delete',
@@ -554,6 +556,9 @@ export async function renderInvoiceList(main, ui) {
554
556
  ...(id === 'pending' && counts?.pending ? { 'data-tone': 'danger' } : {}),
555
557
  onclick: () => { if (state.status !== id) openTab(id); },
556
558
  }, label, h('span', { class: 'fx-tabs__count' }, String(counts?.[id] ?? 0)))));
559
+ // On a phone the tabs scroll, and a redraw starts them at the left: keep
560
+ // the selected one in view, so the page says which tab it shows.
561
+ tabs.children[TABS.findIndex(([id]) => id === state.status)]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
557
562
  }
558
563
 
559
564
  async function draw(answer) {
@@ -587,7 +592,7 @@ export async function renderInvoiceList(main, ui) {
587
592
  const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY, frameY: frame.body.scrollTop }); go(href); };
588
593
  const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
589
594
  const showProblem = state.status === 'pending';
590
- const created = ['pending', 'ready'].includes(state.status);
595
+ const created = ['pending', 'ready', 'non_chargeable'].includes(state.status);
591
596
  // An invoice gets its number when it is sent or issued, so only the
592
597
  // Numbered tabs show a Number column; the others open on Client.
593
598
  const numbered = !created;
@@ -620,7 +625,7 @@ export async function renderInvoiceList(main, ui) {
620
625
  const statusCell = (invoice) => {
621
626
  const cell = h('span', {}, invoice.sending
622
627
  ? h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Sending')
623
- : h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute' }, TAB_LABEL[invoice.tab] ?? ''));
628
+ : h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute', title: TAB_LABEL[invoice.tab] ?? null }, TAB_LABEL[invoice.tab] ?? ''));
624
629
  if (!linked) statusCells.set(invoice.id, cell);
625
630
  return cell;
626
631
  };
@@ -1108,7 +1113,10 @@ function draftView(invoice, ui, context, redraw) {
1108
1113
  if (!saved) return;
1109
1114
  const passes = saved.view === 'ready';
1110
1115
  const why = brief(saved.problems?.[0]?.message);
1111
- if (isNew) {
1116
+ if (saved.view === 'non_chargeable') {
1117
+ toast('Saved. It is Non-chargeable.', 'success');
1118
+ if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved);
1119
+ } else if (isNew) {
1112
1120
  toast(passes ? 'Saved. It is Ready.' : `Saved. It is Pending. ${why}`, passes ? 'success' : 'warn');
1113
1121
  go(`/invoices/${saved.id}`, { replace: true });
1114
1122
  } else if (passes && keepsSave) {
@@ -1135,6 +1143,8 @@ function draftView(invoice, ui, context, redraw) {
1135
1143
  // and keeps it. A Pending draft has no send: fix its problem first.
1136
1144
  const to = invoice?.sendsTo ?? context.sendsTo ?? null;
1137
1145
  const ready = invoice?.view === 'ready';
1146
+ // A lab's rule keeps a non-chargeable draft in Flow: nothing sends or approves it.
1147
+ const nonChargeable = invoice?.view === 'non_chargeable';
1138
1148
  const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
1139
1149
  const emails = invoice?.sendMode?.mode === 'approve_and_email';
1140
1150
  const sendButton = context.canIssue && to && ready ? h('button', {
@@ -1159,7 +1169,7 @@ function draftView(invoice, ui, context, redraw) {
1159
1169
  }, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
1160
1170
  // With no accounting system, Approve gives the draft its number and locks
1161
1171
  // it. A changed job blocks it.
1162
- const issueButton = context.canIssue && !to ? h('button', {
1172
+ const issueButton = context.canIssue && !to && !nonChargeable ? h('button', {
1163
1173
  class: 'fx-btn fx-btn--primary', type: 'button',
1164
1174
  onclick: async () => {
1165
1175
  const saved = await save();
@@ -1242,7 +1252,7 @@ function draftView(invoice, ui, context, redraw) {
1242
1252
 
1243
1253
  const previewBtn = isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.');
1244
1254
  const showAccept = Boolean(jobChange?.canAccept && context.canIssue);
1245
- const header = ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
1255
+ const header = ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : nonChargeable ? 'Non-chargeable invoice' : 'Pending invoice', {
1246
1256
  actions: [fromJob, deleteButton, previewBtn, saveButton, sendButton ?? issueButton].filter(Boolean),
1247
1257
  });
1248
1258
  const primaryOf = () => (showAccept ? null : (sendButton ?? issueButton ?? (saveButton.hidden ? null : saveButton)));
@@ -1260,6 +1270,7 @@ function draftView(invoice, ui, context, redraw) {
1260
1270
  chrome.pager,
1261
1271
  header,
1262
1272
  draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
1273
+ nonChargeable ? ui.notice(brief(invoice.nonChargeable?.[0]?.message ?? 'Non-chargeable.')) : null,
1263
1274
  ...problems,
1264
1275
  h('div', { class: 'fx-invoice__grid' },
1265
1276
  h('div', { class: 'fx-invoice__main' },
@@ -42,7 +42,7 @@ export function orderStatuses(statuses = [], jobs = []) {
42
42
  }
43
43
 
44
44
  export const INVOICE_STAGES = {
45
- drafted: 'Drafted', problem: 'Pending', ready: 'Ready to invoice', invoiced: 'Invoiced', paid: 'Paid',
45
+ drafted: 'Drafted', problem: 'Pending', ready: 'Ready to invoice', invoiced: 'Invoiced', non_chargeable: 'Non-chargeable', paid: 'Paid',
46
46
  };
47
47
 
48
48
  const PROBLEM_VIEWS = ['pending', 'review_required', 'held'];
@@ -58,6 +58,7 @@ const isRule = (reason) => reason !== 'the job is not marked ready to invoice';
58
58
  // that stops the draft, or a problem with the draft
59
59
  // Ready to invoice a person marked the draft ready
60
60
  // Invoiced issued, sent, or in the accounting system
61
+ // Non-chargeable a lab's rule keeps the draft in Flow, never billed
61
62
  // Paid paid in full
62
63
  export function invoiceStage(o) {
63
64
  if (!o || o.view === 'in_work') return null;
@@ -66,6 +67,7 @@ export function invoiceStage(o) {
66
67
  if (o.view === 'invoiced') return 'invoiced';
67
68
  if (o.invoiceView === 'problem' || o.invoiceStatus === 'void') return 'problem';
68
69
  if (o.invoiceStatus === 'issued') return 'invoiced';
70
+ if (o.invoiceView === 'non_chargeable') return 'non_chargeable';
69
71
  if (o.invoiceView === 'ready') return 'ready';
70
72
  if (o.view === 'ready') return (o.waitingFor ?? []).some(isRule) ? 'problem' : 'drafted';
71
73
  if (o.view === 'drafted') return 'drafted';
package/server/ui/jobs.js CHANGED
@@ -50,7 +50,7 @@ const ACTIVITY = {
50
50
  const BY = { person: 'A person', automation: 'An automation' };
51
51
  // Grouped by invoice stage: a job with no invoice stage yet is still in work.
52
52
  const STAGE_GROUPS = { in_work: 'In work', ...INVOICE_STAGES };
53
- const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4, Invoiced: 5, Paid: 6 };
53
+ const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4, Invoiced: 5, 'Non-chargeable': 5, Paid: 6 };
54
54
 
55
55
  const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
56
56
  const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
@@ -585,7 +585,7 @@ export const RULES = {
585
585
  "layout": "scroll"
586
586
  },
587
587
  "tablet": {
588
- "layout": "tabs"
588
+ "layout": "scroll"
589
589
  },
590
590
  "desk": {
591
591
  "layout": "tabs"