@arcforgelabs/flow 2026.10.42 → 2026.10.44

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.
Files changed (40) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +4 -3
  3. package/package.json +5 -3
  4. package/server/auth/people.js +40 -2
  5. package/server/auth/routes.js +11 -0
  6. package/server/auth/session.js +22 -3
  7. package/server/core/custom-fields.js +4 -3
  8. package/server/core/invoice-rules.js +8 -3
  9. package/server/core/job-match.js +6 -2
  10. package/server/core/sending.js +4 -3
  11. package/server/integrations/sentry/index.js +9 -1
  12. package/server/release-channel.js +5 -2
  13. package/server/ui/app-page.js +1 -1
  14. package/server/ui/app.js +46 -162
  15. package/server/ui/automations.js +9 -3
  16. package/server/ui/columns.js +268 -0
  17. package/server/ui/custom-fields.js +17 -16
  18. package/server/ui/flow.css +268 -55
  19. package/server/ui/invoice-notes.js +7 -5
  20. package/server/ui/invoice-rules.js +3 -3
  21. package/server/ui/invoicing-settings.js +92 -35
  22. package/server/ui/invoicing.js +206 -119
  23. package/server/ui/job-board.js +20 -8
  24. package/server/ui/job-change.js +36 -0
  25. package/server/ui/job-view.js +255 -60
  26. package/server/ui/jobs.js +197 -145
  27. package/server/ui/kit.js +278 -0
  28. package/server/ui/list-views.js +73 -0
  29. package/server/ui/monitoring.js +1 -0
  30. package/server/ui/person.js +29 -0
  31. package/server/ui/price-list.js +2 -3
  32. package/server/ui/price-reconcile.js +7 -8
  33. package/server/ui/reconcile.js +114 -61
  34. package/server/ui/routes.js +1 -1
  35. package/server/ui/saved-views.js +141 -0
  36. package/server/ui/search.js +8 -0
  37. package/server/ui/sidecar.js +72 -0
  38. package/server/ui/tip.js +99 -0
  39. package/server/ui/view-bar.js +176 -70
  40. package/server/ui/xero-import.js +7 -10
@@ -9,8 +9,12 @@ import { alertStrip } from '/assets/ui/alerts.js';
9
9
  import { bulkBar } from '/assets/ui/bulk-bar.js';
10
10
  import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
11
11
  import { caseNumber } from '/assets/ui/job-board.js';
12
- import { jobChangeBanner } from '/assets/ui/job-change.js';
13
- import { findBox } from '/assets/ui/view-bar.js';
12
+ import { jobChangeReview } from '/assets/ui/job-change.js';
13
+ import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
14
+ import { filterJobs, INVOICE_COLUMNS, normaliseListView } from '/assets/ui/job-view.js';
15
+ import { INVOICE_FILTER_FIELDS, normaliseInvoicesView } from '/assets/ui/list-views.js';
16
+ import { savedViews } from '/assets/ui/saved-views.js';
17
+ import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
14
18
 
15
19
  // The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
16
20
  // person must look), Ready (checks pass), then the accounting system's
@@ -110,14 +114,57 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
110
114
 
111
115
  // ── The list ───────────────────────────────────────────────────────────────
112
116
 
117
+ // The filter's fields: list-views.js (the server checks saved views with them).
118
+ const FILTER_FIELDS = INVOICE_FILTER_FIELDS;
119
+ const PROBLEM_OPS = { yes: 'Has one', no: 'Has none' };
120
+ const FILTER_KEY = 'flow_invoice_filters';
121
+ const filterKind = (field) => FILTER_FIELDS[field]?.kind ?? null;
122
+
123
+ // An invoice's value for a filter field, as the filter compares it.
124
+ export function invoiceValue(invoice, field) {
125
+ switch (field) {
126
+ case 'client': return invoice.contactName ?? null;
127
+ case 'number': return invoice.number ?? null;
128
+ case 'reference': return invoice.reference ?? null;
129
+ case 'date': return invoice.issueDate ?? invoice.createdAt?.slice(0, 10) ?? null;
130
+ case 'total': return invoice.totalCents == null ? null : invoice.totalCents / 100;
131
+ case 'problem': return (invoice.problems?.length ?? 0) > 0;
132
+ default: return null;
133
+ }
134
+ }
135
+
136
+ // The invoices a filter view keeps.
137
+ export const keptInvoices = (invoices, view, now = today()) => filterJobs(invoices, view, { valueOf: invoiceValue, kindOf: filterKind, today: now });
138
+
139
+ function readFilters() {
140
+ try { return normaliseInvoicesView(JSON.parse(localStorage.getItem(FILTER_KEY))); } catch { return normaliseInvoicesView(null); }
141
+ }
142
+
113
143
  export async function renderInvoiceList(main, ui) {
114
- const { h, flow, pageHeader, notice, go, toast } = ui;
144
+ const { h, flow, listHead, listFrame, notice, go, toast } = ui;
115
145
  const saved = OLD_TAB[sessionStorage.getItem('flow_invoice_tab')] ?? sessionStorage.getItem('flow_invoice_tab');
116
146
  // Back from an invoice comes here with the tab, search and scroll it left.
117
147
  const back = readNav();
118
148
  const returning = sessionStorage.getItem(NAV_BACK) === '1';
119
149
  sessionStorage.removeItem(NAV_BACK);
120
- const state = { status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set() };
150
+ const state = {
151
+ status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set(), view: readFilters(),
152
+ columns: normaliseListView(ui.me?.views?.invoices, INVOICE_COLUMNS),
153
+ };
154
+ // The list's columns: resize, move, hide, kept with the account. A tab
155
+ // has some of them (`available`, set as it draws).
156
+ let available = Object.keys(INVOICE_COLUMNS);
157
+ const saveColumns = viewSaver(ui, 'invoices');
158
+ const heads = columnTable(h, {
159
+ order: () => state.columns.columns,
160
+ widths: () => state.columns.widths,
161
+ change: (patch) => {
162
+ state.columns = normaliseListView({ ...state.columns, ...patch }, INVOICE_COLUMNS);
163
+ saveColumns(state.columns);
164
+ if (raw) draw(raw);
165
+ },
166
+ hidden: () => available.filter((id) => !state.columns.columns.includes(id)).map((id) => ({ id, label: INVOICE_COLUMNS[id] })),
167
+ });
121
168
  const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
122
169
  let timer;
123
170
  const find = findBox(h, {
@@ -127,31 +174,50 @@ export async function renderInvoiceList(main, ui) {
127
174
  timer = setTimeout(() => { state.q = words.trim(); load(); }, 200);
128
175
  },
129
176
  });
177
+ // The Filter chip: the same panel as on Jobs and Reconcile (view-bar.js).
178
+ // It filters the rows of the tab shown, on this device.
179
+ // A change to the filter: kept in this browser, and drawn.
180
+ function setView(patch) {
181
+ state.view = normaliseInvoicesView({ ...state.view, ...patch });
182
+ try { localStorage.setItem(FILTER_KEY, JSON.stringify(state.view)); } catch { /* the page still works, it forgets */ }
183
+ views.draw();
184
+ if (raw) draw(raw);
185
+ }
186
+ // The person's named views (saved-views.js): their default opens on a
187
+ // new visit.
188
+ const views = savedViews(ui, { page: 'invoices', current: () => state.view, apply: setView });
189
+ const opening = views.opening();
190
+ if (opening) state.view = normaliseInvoicesView(opening);
191
+ views.draw();
192
+ const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
193
+ const filterMenu = viewBar(h).filterMenu({
194
+ fields: () => Object.entries(FILTER_FIELDS).map(([value, f]) => ({ value, label: f.label, icon: f.icon })),
195
+ kindOf: filterKind,
196
+ opsOf: (field) => (field === 'problem' ? PROBLEM_OPS : null),
197
+ choicesOf: (field) => (field === 'client' ? labels((raw?.invoices ?? []).map((i) => i.contactName)) : []),
198
+ view: () => state.view,
199
+ change: setView,
200
+ first: 'client',
201
+ empty: 'No filters. Every invoice in the tab shows.',
202
+ });
203
+ const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Filter' }, ...filterMenu.node);
130
204
  const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
131
205
  const bulk = bulkBar(ui);
132
206
  const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
133
207
  let body = null;
208
+ let raw = null;
134
209
  const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
135
210
  const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
136
211
  // The tabs and search stay at the top while the rows scroll up under them;
137
212
  // the bulk bar floats at the foot (ClickUp's layout). Sending is a bulk
138
213
  // action: tick rows (the head tick takes every row shown), then Send.
139
- const head = h('div', { class: 'fx-listhead' }, h('div', { class: 'fx-toolbar' }, tabs, find.element));
140
- const edge = h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' });
141
- main.append(
142
- pageHeader('Invoices', {
143
- actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
144
- }),
145
- alerts.element,
146
- edge,
147
- head,
148
- host,
149
- bulk.element);
150
- // A shadow under the head shows once rows scroll under it.
151
- if (typeof IntersectionObserver === 'function') {
152
- new IntersectionObserver(([entry]) => head.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${head.offsetTop > 0 && window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1}px 0px 0px 0px` }).observe(edge);
153
- }
154
-
214
+ const head = listHead('Invoices', {
215
+ actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
216
+ views: views.element, start: tabs, end: [controls, find.element], line: true, sticky: true,
217
+ });
218
+ const [title, ...top] = head.nodes;
219
+ const frame = listFrame(main, host);
220
+ main.append(title, alerts.element, ...top, frame.element, bulk.element);
155
221
 
156
222
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
157
223
  // The bulk bar for the ticked rows: what a person can do with this view.
@@ -346,20 +412,25 @@ export async function renderInvoiceList(main, ui) {
346
412
  }
347
413
 
348
414
  async function draw(answer) {
349
- body = answer;
415
+ raw = answer;
416
+ filterMenu.draw();
417
+ body = { ...answer, invoices: keptInvoices(answer.invoices, state.view) };
350
418
  const shown = new Set(body.invoices.map((i) => i.id));
419
+ frame.count(plural(body.invoices.length, 'invoice', 'invoices'));
351
420
  for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
352
421
  drawTabs(body.counts);
353
422
  drawBar();
354
423
  if (!body.invoices.length) {
355
- host.replaceChildren(h('p', { class: 'fx-empty' }, state.q
424
+ host.replaceChildren(h('p', { class: 'fx-empty' }, answer.invoices.length
425
+ ? 'No invoice in this tab passes the filter.'
426
+ : state.q
356
427
  ? 'No invoice matches that search.'
357
428
  : EMPTY[state.status] ?? `No ${TAB_LABEL[state.status].toLowerCase()} invoices.`));
358
429
  return;
359
430
  }
360
431
  // The invoice page steps through this list, in this order (navBar).
361
432
  saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
362
- const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
433
+ const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY, frameY: frame.body.scrollTop }); go(href); };
363
434
  const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
364
435
  const showProblem = state.status === 'pending';
365
436
  const created = ['pending', 'ready'].includes(state.status);
@@ -391,37 +462,47 @@ export async function renderInvoiceList(main, ui) {
391
462
  },
392
463
  });
393
464
  const statusCells = new Map();
394
- host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
395
- h('thead', {}, h('tr', {},
396
- h('th', { class: 'fx-tick-cell' }, all),
397
- numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
398
- h('th', {}, created ? 'Created' : 'Date'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'),
399
- linked ? h('th', {}, 'Linked') : null)),
400
- h('tbody', {}, body.invoices.map((invoice) => {
401
- const href = `/invoices/${invoice.id}`;
402
- const first = invoice.problems?.[0];
403
- return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
404
- h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)),
405
- numbered ? h('td', {}, link(href, invoice.number ?? 'Draft')) : null,
406
- h('td', {}, numbered ? (invoice.contactName ?? '—') : link(href, invoice.contactName ?? 'No client')),
407
- showProblem
408
- ? h('td', { class: 'fx-problem', title: invoice.problems.map((p) => p.message).join(' ') }, first?.short ?? first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1}`) : null)
409
- : h('td', { class: 'fx-dim' }, invoice.reference ?? '—'),
410
- h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
411
- h('td', { class: 'fx-num fx-strong' }, formatCents(invoice.totalCents)),
412
- (() => {
413
- const cell = h('td', { class: 'fx-status-cell' }, invoice.sending
414
- ? h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Sending')
415
- : h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute' }, TAB_LABEL[invoice.tab] ?? ''));
416
- if (!linked) statusCells.set(invoice.id, cell);
417
- return cell;
418
- })(),
419
- linked ? (() => {
420
- const cell = h('td', { class: 'fx-links' }, systems.filter((s) => s.kind === 'work').map((s) => workIcon(ui, s, invoice.job)));
421
- statusCells.set(invoice.id, cell);
422
- return cell;
423
- })() : null);
424
- }))));
465
+ // The columns this tab has, in the person's order (columns.js).
466
+ const statusCell = (invoice) => {
467
+ const cell = h('span', {}, invoice.sending
468
+ ? h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Sending')
469
+ : h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute' }, TAB_LABEL[invoice.tab] ?? ''));
470
+ if (!linked) statusCells.set(invoice.id, cell);
471
+ return cell;
472
+ };
473
+ const linkCell = (invoice) => {
474
+ const cell = h('span', { class: 'fx-links' }, systems.filter((s) => s.kind === 'work').map((s) => workIcon(ui, s, invoice.job)));
475
+ statusCells.set(invoice.id, cell);
476
+ return cell;
477
+ };
478
+ const problemCell = (invoice) => {
479
+ const first = invoice.problems?.[0];
480
+ return h('span', { class: 'fx-problem', title: invoice.problems.map((p) => p.message).join(' ') },
481
+ first?.short ?? first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1}`) : null);
482
+ };
483
+ const href = (invoice) => `/invoices/${invoice.id}`;
484
+ const ALL = {
485
+ number: numbered ? { kind: 'number', cell: (i) => link(href(i), i.number ?? 'Draft') } : null,
486
+ client: { kind: 'client', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
487
+ reference: showProblem
488
+ ? { label: 'Problem', kind: 'text', cell: problemCell }
489
+ : { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
490
+ date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) => showDate(i.issueDate ?? i.createdAt?.slice(0, 10)) },
491
+ total: { kind: 'total', num: true, className: 'fx-strong', cell: (i) => formatCents(i.totalCents) },
492
+ status: { kind: 'status', className: 'fx-status-cell', cell: statusCell },
493
+ linked: linked ? { kind: 'link', cell: linkCell } : null,
494
+ };
495
+ available = Object.keys(ALL).filter((id) => ALL[id]);
496
+ const columns = state.columns.columns.filter((id) => ALL[id]).map((id) => ({ id, label: INVOICE_COLUMNS[id], ...ALL[id] }));
497
+ host.replaceChildren(heads.table({
498
+ className: 'fx-table--rows',
499
+ columns,
500
+ lead: [h('th', { class: 'fx-tick-cell' }, all)],
501
+ rows: h('tbody', {}, body.invoices.map((invoice) => h('tr', { tabindex: '0', onclick: () => open(href(invoice)), onkeydown: (event) => { if (event.key === 'Enter') open(href(invoice)); } },
502
+ h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)),
503
+ rowCells(h, columns, invoice),
504
+ h('td', { class: 'fx-c-end' })))),
505
+ }));
425
506
  // Issued and void invoices also show Xero's status, from Flow's copy:
426
507
  // in the Linked column every one gets an icon, so one not in Xero
427
508
  // stands out.
@@ -437,6 +518,7 @@ export async function renderInvoiceList(main, ui) {
437
518
  }
438
519
  await load();
439
520
  if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
521
+ if (returning && back?.frameY) frame.body.scrollTop = back.frameY;
440
522
  }
441
523
 
442
524
  // A file the browser saves, from a blob made on this page.
@@ -499,7 +581,8 @@ function navBar(ui, invoice) {
499
581
  return bar;
500
582
  }
501
583
 
502
- // After a send, an Approve or a Save as draft, the next invoice of the list
584
+ // After a send, an Approve or a Save that makes a Pending invoice Ready, the
585
+ // next invoice of the list
503
586
  // opens; at its end, the list. So a person reviews drafts one after another
504
587
  // without going back. The invoice stays in the bar's list, so the count holds
505
588
  // (2 of 35) and Previous opens it again, now done. Back reloads the list
@@ -595,9 +678,13 @@ function linkedCard(ui, invoice, accountingStatus = null) {
595
678
  facts);
596
679
  }
597
680
 
681
+ // The first sentence of a problem: what is wrong. The red field shows where,
682
+ // so the banner holds no how-to.
683
+ const brief = (text) => String(text ?? '').match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? String(text ?? '');
684
+
598
685
  // A draft, or a new invoice before its first save.
599
686
  function draftView(invoice, ui, context, redraw) {
600
- const { h, flow, toast, go, notice } = ui;
687
+ const { h, flow, toast, go, notice, fieldError } = ui;
601
688
  const isNew = !invoice;
602
689
  const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
603
690
  if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
@@ -636,9 +723,18 @@ function draftView(invoice, ui, context, redraw) {
636
723
  });
637
724
  clientInput.addEventListener('blur', () => { setTimeout(() => { clientList.hidden = true; }, 120); });
638
725
 
639
- const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: 'Practitioner - Patient' });
726
+ const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: 'Type a reference', 'aria-label': 'Reference' });
640
727
  const dueDate = h('input', { class: 'fx-input', type: 'date', value: invoice?.dueDate ?? '' });
641
728
 
729
+ // The fields a failed check reads go red (problem.fields): a rule on the
730
+ // Reference marks the Reference box, a line with no price item marks its
731
+ // item. A check on something this page cannot change (the job's
732
+ // practitioner) marks nothing; the banner says it.
733
+ const problemList = invoice?.problems ?? [];
734
+ const failed = new Set(problemList.flatMap((p) => p.fields ?? []));
735
+ const badLines = new Set(problemList.filter((p) => p.fields?.includes('line_item')).map((p) => p.line));
736
+ const boxes = { reference };
737
+
642
738
  // Lines.
643
739
  let items = null;
644
740
  const itemsReady = flow('/api/invoicing/price-items').then((body) => { items = body.priceItems ?? body.items ?? []; return items; }).catch(() => { items = []; return items; });
@@ -665,9 +761,10 @@ function draftView(invoice, ui, context, redraw) {
665
761
  };
666
762
  showAmount();
667
763
  const item = h('input', {
668
- class: 'fx-input', type: 'text', list: 'fx-price-items', placeholder: 'Code or name', autocomplete: 'off',
764
+ class: 'fx-input', type: 'text', list: 'fx-price-items', placeholder: 'Code', autocomplete: 'off',
669
765
  value: line.itemCode ?? '', 'aria-label': `Item, line ${index + 1}`,
670
766
  });
767
+ if (badLines.has(index + 1)) fieldError(item);
671
768
  const description = h('input', { class: 'fx-input', type: 'text', value: line.description ?? '', 'aria-label': `Description, line ${index + 1}` });
672
769
  const quantity = h('input', { class: 'fx-input fx-num', type: 'number', min: '1', step: '1', value: String(line.quantity ?? 1), 'aria-label': `Quantity, line ${index + 1}` });
673
770
  const price = h('input', { class: 'fx-input fx-num', type: 'text', inputmode: 'decimal', value: fromCents(line.unitAmountCents), placeholder: '0.00', 'aria-label': `Unit price, line ${index + 1}` });
@@ -693,7 +790,7 @@ function draftView(invoice, ui, context, redraw) {
693
790
  gst.addEventListener('change', () => { line.taxCode = gst.value; preview(); });
694
791
  const remove = h('button', {
695
792
  class: 'fx-btn fx-btn--ghost fx-btn--sm fx-btn--icon', type: 'button', title: 'Remove line', 'aria-label': `Remove line ${index + 1}`,
696
- onclick: () => { lines.splice(index, 1); if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); preview(); },
793
+ onclick: () => { badLines.clear(); lines.splice(index, 1); if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); preview(); },
697
794
  }, '×');
698
795
  return h('tr', {},
699
796
  h('td', { class: 'fx-lines__item' }, item),
@@ -734,7 +831,7 @@ function draftView(invoice, ui, context, redraw) {
734
831
  });
735
832
 
736
833
  const check = () => {
737
- if (!contact) return 'Choose a client from the list.';
834
+ if (!contact) { fieldError(clientInput); return 'Choose a client from the list.'; }
738
835
  if (lines.some((line) => (line.description?.trim() || line.priceItemId) && line.unitAmountCents == null)) return 'Each line needs a price.';
739
836
  return null;
740
837
  };
@@ -753,22 +850,38 @@ function draftView(invoice, ui, context, redraw) {
753
850
  }
754
851
  };
755
852
 
756
- // Save shows only when there is something to save: a new invoice, or a
757
- // change since the page opened. Save as draft opens the next invoice.
758
- // Save as draft: a draft that is not ready always offers it. A ready
759
- // invoice offers Save changes only once something changed.
853
+ // Save is the check, like CI: it saves, and Flow runs every check again
854
+ // (Flow's own, the lab's rules, the accounting system's). A Pending
855
+ // invoice that now passes is Ready: a toast says so and the next invoice
856
+ // of the list opens. One that still fails stays Pending, with its red
857
+ // fields. A new invoice and a Pending one always offer Save; a Ready one
858
+ // only once something changed. There is no Flow "draft" step: Draft is
859
+ // the accounting system's word for an invoice it holds.
760
860
  const keepsSave = isNew || invoice?.view !== 'ready';
761
861
  const saveButton = h('button', {
762
862
  class: 'fx-btn', type: 'button', hidden: !keepsSave,
763
863
  onclick: async () => {
864
+ saveButton.disabled = true;
764
865
  const saved = await save();
866
+ saveButton.disabled = false;
765
867
  if (!saved) return;
766
- toast(keepsSave ? 'Saved as a draft.' : 'Changes saved.', 'success');
767
- if (isNew) go(`/invoices/${saved.id}`, { replace: true });
768
- else if (keepsSave) openNext(ui, invoice.id);
769
- else redraw(saved);
868
+ const passes = saved.view === 'ready';
869
+ const why = brief(saved.problems?.[0]?.message);
870
+ if (isNew) {
871
+ toast(passes ? 'Saved. It is Ready.' : `Saved. It is Pending. ${why}`, passes ? 'success' : 'warn');
872
+ go(`/invoices/${saved.id}`, { replace: true });
873
+ } else if (passes && keepsSave) {
874
+ toast('Saved. It is Ready.', 'success');
875
+ openNext(ui, invoice.id);
876
+ } else if (passes) {
877
+ toast('Saved.', 'success');
878
+ redraw(saved);
879
+ } else {
880
+ toast(`Still Pending. ${why}`, 'danger');
881
+ redraw(saved);
882
+ }
770
883
  },
771
- }, keepsSave ? 'Save as draft' : 'Save changes');
884
+ }, 'Save');
772
885
  const opened = JSON.stringify(body());
773
886
  const changed = () => !isNew && JSON.stringify(body()) !== opened;
774
887
  const watchChanges = () => { if (!keepsSave) saveButton.hidden = !changed(); };
@@ -836,15 +949,12 @@ function draftView(invoice, ui, context, redraw) {
836
949
  dueDate.addEventListener('change', syncIssue);
837
950
  syncIssue();
838
951
 
839
- // A new invoice can start from a job that has no invoice yet: a quiet
840
- // strip above the form says so. Flow drafts it the way the invoicing rules
841
- // do (client, Reference, lines) and links it to the job. An invoice with
842
- // no job is fine too.
843
- const fromJob = isNew && context.canIssue ? h('section', { class: 'fx-fromjob' },
844
- h('div', { class: 'fx-fromjob__text' },
845
- h('span', { class: 'fx-strong' }, 'Billing a job?'),
846
- h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
847
- h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
952
+ // A new invoice can start from a job that has no invoice yet: a button in
953
+ // the header. Flow drafts it the way the invoicing rules do (client,
954
+ // Reference, lines) and links it to the job. An invoice with no job is
955
+ // fine too.
956
+ const fromJob = isNew && context.canIssue
957
+ ? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => pickJob(ui) }, 'From a job') : null;
848
958
 
849
959
  // A draft with no problem is in Ready by itself; one with a problem is
850
960
  // Pending. A changed job is fixed at its source or accepted here.
@@ -853,47 +963,26 @@ function draftView(invoice, ui, context, redraw) {
853
963
  // changed. Other problems stay one line.
854
964
  const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
855
965
  const problems = [
856
- jobChange ? jobChangeBanner(ui, jobChange, {
857
- money: formatCents,
858
- // Accepting changes the draft for the business: owners and admins only.
859
- onAccept: !context.canIssue ? null : async () => {
860
- const system = jobChange.systemName;
861
- const lost = [jobChange.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, changed() ? 'Unsaved changes are lost.' : null].filter(Boolean);
862
- if (lost.length) {
863
- const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
864
- if (!ok) return;
865
- }
866
- try {
867
- const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
868
- toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
869
- redraw(res.invoice);
870
- } catch (error) {
871
- toast(error.message, 'danger');
872
- }
873
- },
874
- // Reads this one job from the work system again, then every check runs.
875
- onRecheck: async () => {
876
- try {
877
- const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/recheck`, { method: 'POST', body: '{}' });
878
- toast(res.invoice?.jobChange ? `Still changed in ${jobChange.systemName}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
879
- if (res.invoice) redraw(res.invoice);
880
- } catch (error) {
881
- toast(error.message, 'danger');
882
- }
883
- },
884
- }) : null,
885
- otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
966
+ // Accepting changes the draft for the business: owners and admins only.
967
+ jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
968
+ otherProblems.length ? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.') : null,
886
969
  ];
970
+ // A red field names the banner as its reason (for a screen reader).
971
+ const banner = problems[1];
972
+ if (banner) banner.id = 'fx-invoice-problem';
973
+ for (const name of failed) {
974
+ if (!boxes[name]) continue;
975
+ fieldError(boxes[name]);
976
+ if (banner) boxes[name].setAttribute('aria-describedby', banner.id);
977
+ }
887
978
 
888
979
  return [
889
980
  navBar(ui, invoice),
890
- h('header', { class: 'fx-ph' },
891
- h('div', { class: 'fx-ph__text' },
892
- h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice'),
893
- h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
894
- h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton)),
981
+ ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
982
+ actions: [fromJob, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton].filter(Boolean),
983
+ }),
984
+ draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
895
985
  ...problems,
896
- fromJob,
897
986
  h('div', { class: 'fx-invoice__grid' },
898
987
  h('div', { class: 'fx-invoice__main' },
899
988
  h('section', { class: 'fx-card fx-invoice__head' },
@@ -999,10 +1088,8 @@ function issuedView(invoice, ui, context, redraw) {
999
1088
  navBar(ui, invoice),
1000
1089
  h('header', { class: 'fx-ph' },
1001
1090
  h('div', { class: 'fx-ph__text' },
1002
- h('div', { class: 'fx-row' },
1003
- h('h1', { class: 'fx-title' }, invoice.number),
1004
- h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
1005
- h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · ${showDate(invoice.issueDate)}`)),
1091
+ h('h1', { class: 'fx-title' }, invoice.number),
1092
+ h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
1006
1093
  h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
1007
1094
  h('div', { class: 'fx-invoice__grid' },
1008
1095
  h('div', { class: 'fx-invoice__main' },
@@ -1072,7 +1159,7 @@ function xeroCard(invoice, ui, context, status, reload) {
1072
1159
  }
1073
1160
 
1074
1161
  // Send to Xero (POST /api/xero/invoices/push). The customer's send mode
1075
- // (Settings, Invoice rules) decides what Xero gets: a draft that a person approves
1162
+ // (Settings, Rules) decides what Xero gets: a draft that a person approves
1076
1163
  // there, or an approved invoice that Xero emails to the customer.
1077
1164
  function sendToXero(invoice, ui, context) {
1078
1165
  const { h, flow, toast } = ui;
@@ -93,7 +93,10 @@ function reasonFix(reason) {
93
93
  if (reason === 'the work system report is out of date') {
94
94
  return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.', go: 'integrations' };
95
95
  }
96
- if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Open the invoice to see what changed. Fix the job in the work system, or adopt its changes on the draft.', 'invoice');
96
+ // Review opens the job's own sidecar: what changed, and Accept change.
97
+ if (reason === 'the job changed after its invoice was made') {
98
+ return { label: 'Job changed', text: 'Changed after the draft was made.', todo: 'Fix it in the work system, or accept the change.', go: 'review' };
99
+ }
97
100
  if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.', 'work');
98
101
  if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.', 'work');
99
102
  // The matcher found invoices that may be this job's, but no exact link.
@@ -134,27 +137,36 @@ function draftFix(problem) {
134
137
  }
135
138
 
136
139
  // Where a fix's link goes: Flow's invoice, the job in the work system, a
137
- // Flow settings page, or `choose`: Make invoice and Link invoice buttons
138
- // (jobs.js draws them).
139
- export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'choose'];
140
+ // Flow settings page, `review`: the job's Review sidecar, or `choose`: Make
141
+ // invoice and Link invoice buttons (jobs.js draws them).
142
+ export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'review', 'choose'];
140
143
 
141
144
  // What a person must fix on a job, in order: [{ label, text, todo, go }];
142
145
  // `todo` is null when the buttons are the instruction.
143
- // Empty unless invoiceStage is Problem.
146
+ // Empty unless invoiceStage is Problem. One fact is one fix: a job in review
147
+ // already says why, so its draft's copy of that hold (sending.js,
148
+ // `job_review`) is left out, and two fixes with the same words show once.
144
149
  export function fixes(o) {
145
150
  if (invoiceStage(o) !== 'problem') return [];
146
151
  const found = [];
147
152
  if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
148
153
  if (o.view === 'ready') {
149
154
  found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
150
- label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Invoice rules.', go: 'invoicing-settings',
155
+ label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Rules.', go: 'invoicing-settings',
151
156
  })));
152
157
  }
153
158
  if (o.invoiceStatus === 'void') {
154
159
  found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
155
160
  }
156
- found.push(...(o.invoiceProblems ?? []).map((p) => draftFix(p)));
157
- return found;
161
+ const held = o.view === 'review_required' && o.holdState === 'review';
162
+ found.push(...(o.invoiceProblems ?? []).filter((p) => !(held && p.code === 'job_review')).map((p) => draftFix(p)));
163
+ const seen = new Set();
164
+ return found.filter((f) => {
165
+ const key = `${f.label}\u0000${f.text}`;
166
+ if (seen.has(key)) return false;
167
+ seen.add(key);
168
+ return true;
169
+ });
158
170
  }
159
171
 
160
172
  // The short words for a job's problems: the first label, and "+N" for more.
@@ -87,3 +87,39 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
87
87
  body)) : null,
88
88
  more);
89
89
  }
90
+
91
+ // The banner with Accept change and the recheck wired, for the invoice page
92
+ // and a job's Review sidecar (jobs.js). `canAccept`: the person may change
93
+ // drafts (owners and admins). `unsaved()`: edits on the page that Accept
94
+ // would lose. `done(invoice)` hears the invoice after Accept or a recheck.
95
+ export function jobChangeReview(ui, change, { money, canAccept = false, unsaved = () => false, done = () => {} }) {
96
+ const { h, flow, toast } = ui;
97
+ const system = change.systemName;
98
+ return jobChangeBanner(ui, change, {
99
+ money,
100
+ onAccept: !canAccept ? null : async () => {
101
+ const lost = [change.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, unsaved() ? 'Unsaved changes are lost.' : null].filter(Boolean);
102
+ if (lost.length) {
103
+ const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
104
+ if (!ok) return;
105
+ }
106
+ try {
107
+ const res = await flow(`/api/invoicing/jobs/${change.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
108
+ toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
109
+ await done(res.invoice);
110
+ } catch (error) {
111
+ toast(error.message, 'danger');
112
+ }
113
+ },
114
+ // Reads this one job from the work system again, then every check runs.
115
+ onRecheck: async () => {
116
+ try {
117
+ const res = await flow(`/api/invoicing/jobs/${change.jobId}/recheck`, { method: 'POST', body: '{}' });
118
+ toast(res.invoice?.jobChange ? `Still changed in ${system}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
119
+ if (res.invoice) await done(res.invoice);
120
+ } catch (error) {
121
+ toast(error.message, 'danger');
122
+ }
123
+ },
124
+ });
125
+ }