@arcforgelabs/flow 2026.10.43 → 2026.10.45

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.
@@ -10,7 +10,12 @@ 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
12
  import { jobChangeReview } from '/assets/ui/job-change.js';
13
- import { findBox } from '/assets/ui/view-bar.js';
13
+ import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
14
+ import { filterCount, filterJobs, INVOICE_COLUMNS, normaliseListView } from '/assets/ui/job-view.js';
15
+ import { INVOICE_FILTER_FIELDS, invoiceFilterKind, invoiceValue, normaliseInvoicesView } from '/assets/ui/list-views.js';
16
+ import { listPager, PAGE_SIZES } from '/assets/ui/pager.js';
17
+ import { savedViews } from '/assets/ui/saved-views.js';
18
+ import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
14
19
 
15
20
  // The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
16
21
  // person must look), Ready (checks pass), then the accounting system's
@@ -110,48 +115,123 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
110
115
 
111
116
  // ── The list ───────────────────────────────────────────────────────────────
112
117
 
118
+ // The filter's fields and rules: list-views.js. The server checks saved
119
+ // views with them and filters every invoice of a tab (GET /invoices).
120
+ const FILTER_FIELDS = INVOICE_FILTER_FIELDS;
121
+ const PROBLEM_OPS = { yes: 'Has one', no: 'Has none' };
122
+ const FILTER_KEY = 'flow_invoice_filters';
123
+ const SIZE_KEY = 'flow_invoice_page_size';
124
+ const filterKind = invoiceFilterKind;
125
+ export { invoiceValue };
126
+
127
+ // The invoices a filter view keeps.
128
+ export const keptInvoices = (invoices, view, now = today()) => filterJobs(invoices, view, { valueOf: invoiceValue, kindOf: filterKind, today: now });
129
+
130
+ // Rows per page, kept on this device.
131
+ function readSize() {
132
+ let size = null;
133
+ try { size = Number(localStorage.getItem(SIZE_KEY)); } catch { /* none kept */ }
134
+ return PAGE_SIZES.includes(size) ? size : 100;
135
+ }
136
+
137
+ function readFilters() {
138
+ try { return normaliseInvoicesView(JSON.parse(localStorage.getItem(FILTER_KEY))); } catch { return normaliseInvoicesView(null); }
139
+ }
140
+
113
141
  export async function renderInvoiceList(main, ui) {
114
- const { h, flow, pageHeader, notice, go, toast } = ui;
142
+ const { h, flow, listHead, listFrame, notice, go, toast } = ui;
115
143
  const saved = OLD_TAB[sessionStorage.getItem('flow_invoice_tab')] ?? sessionStorage.getItem('flow_invoice_tab');
116
144
  // Back from an invoice comes here with the tab, search and scroll it left.
117
145
  const back = readNav();
118
146
  const returning = sessionStorage.getItem(NAV_BACK) === '1';
119
147
  sessionStorage.removeItem(NAV_BACK);
120
- const state = { status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set() };
148
+ const state = {
149
+ status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set(), view: readFilters(),
150
+ // The page of rows shown: the server filters and cuts it (GET /invoices).
151
+ offset: returning ? back?.offset ?? 0 : 0, limit: readSize(),
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, {
124
171
  label: 'Find a number, client or reference', value: state.q,
125
172
  oninput: (words) => {
126
173
  clearTimeout(timer);
127
- timer = setTimeout(() => { state.q = words.trim(); load(); }, 200);
174
+ timer = setTimeout(() => { state.q = words.trim(); state.offset = 0; load(); }, 200);
128
175
  },
129
176
  });
177
+ // The Filter chip: the same panel as on Jobs and Reconcile (view-bar.js).
178
+ // The server filters every invoice of the tab shown.
179
+ // A change to the filter: kept in this browser, and the first page loads.
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
+ state.offset = 0;
185
+ if (raw) load();
186
+ }
187
+ // The person's named views (saved-views.js): their default opens on a
188
+ // new visit.
189
+ const views = savedViews(ui, { page: 'invoices', current: () => state.view, apply: setView });
190
+ const opening = views.opening();
191
+ if (opening) state.view = normaliseInvoicesView(opening);
192
+ views.draw();
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' ? (raw?.clients ?? []).map((v) => ({ value: v, label: v })) : []),
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);
206
+ // The pages, in the foot under the rows (pager.js).
207
+ const pager = listPager(h, {
208
+ label: 'Invoice pages',
209
+ change: ({ offset, limit }) => {
210
+ if (limit !== state.limit) { try { localStorage.setItem(SIZE_KEY, String(limit)); } catch { /* it forgets */ } }
211
+ Object.assign(state, { offset, limit });
212
+ state.ticked.clear();
213
+ // A new page opens at its first row.
214
+ frame.body.scrollTop = 0;
215
+ load();
216
+ },
217
+ });
132
218
  const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
133
219
  let body = null;
134
- const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
220
+ let raw = null;
221
+ const openTab = (id) => { state.status = id; state.offset = 0; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
135
222
  const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
136
223
  // The tabs and search stay at the top while the rows scroll up under them;
137
224
  // the bulk bar floats at the foot (ClickUp's layout). Sending is a bulk
138
225
  // 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
-
226
+ const head = listHead('Invoices', {
227
+ actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
228
+ views: views.element, start: tabs, end: [controls, find.element], line: true, sticky: true,
229
+ });
230
+ const [title, ...top] = head.nodes;
231
+ const frame = listFrame(main, host);
232
+ // The pages sit in the window's foot bar, beside the count.
233
+ frame.tools.append(pager.element);
234
+ main.append(title, alerts.element, ...top, frame.element, bulk.element);
155
235
 
156
236
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
157
237
  // The bulk bar for the ticked rows: what a person can do with this view.
@@ -307,7 +387,11 @@ export async function renderInvoiceList(main, ui) {
307
387
  const seen = new Map();
308
388
  let asked = 0;
309
389
  async function load() {
310
- const query = new URLSearchParams({ status: state.status, ...(state.q ? { q: state.q } : {}) });
390
+ const query = new URLSearchParams({
391
+ status: state.status, offset: String(state.offset), limit: String(state.limit), today: today(),
392
+ ...(state.q ? { q: state.q } : {}),
393
+ ...(filterCount(state.view, filterKind) ? { filter: JSON.stringify(state.view) } : {}),
394
+ });
311
395
  const key = String(query);
312
396
  const mine = ++asked;
313
397
  if (seen.has(key)) {
@@ -346,20 +430,33 @@ export async function renderInvoiceList(main, ui) {
346
430
  }
347
431
 
348
432
  async function draw(answer) {
433
+ raw = answer;
434
+ filterMenu.draw();
349
435
  body = answer;
436
+ // A page past the end (rows went to another tab): show the last one.
437
+ if (!answer.invoices.length && answer.total && answer.offset) {
438
+ state.offset = Math.max(0, Math.floor((answer.total - 1) / state.limit) * state.limit);
439
+ load();
440
+ return;
441
+ }
442
+ pager.draw({ offset: answer.offset ?? 0, limit: answer.limit ?? state.limit, total: answer.total ?? answer.invoices.length });
350
443
  const shown = new Set(body.invoices.map((i) => i.id));
444
+ const total = answer.total ?? body.invoices.length;
445
+ frame.count(`${total.toLocaleString('en-AU')} ${total === 1 ? 'invoice' : 'invoices'}${total ? ` · ${formatCents(answer.totalCents ?? 0)}` : ''}`);
351
446
  for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
352
447
  drawTabs(body.counts);
353
448
  drawBar();
354
449
  if (!body.invoices.length) {
355
- host.replaceChildren(h('p', { class: 'fx-empty' }, state.q
450
+ host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, filterKind)
451
+ ? 'No invoice in this tab passes the filter.'
452
+ : state.q
356
453
  ? 'No invoice matches that search.'
357
454
  : EMPTY[state.status] ?? `No ${TAB_LABEL[state.status].toLowerCase()} invoices.`));
358
455
  return;
359
456
  }
360
457
  // The invoice page steps through this list, in this order (navBar).
361
- 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); };
458
+ saveNav({ tab: state.status, q: state.q, offset: state.offset, ids: body.invoices.map((i) => i.id) });
459
+ const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY, frameY: frame.body.scrollTop }); go(href); };
363
460
  const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
364
461
  const showProblem = state.status === 'pending';
365
462
  const created = ['pending', 'ready'].includes(state.status);
@@ -391,37 +488,47 @@ export async function renderInvoiceList(main, ui) {
391
488
  },
392
489
  });
393
490
  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
- }))));
491
+ // The columns this tab has, in the person's order (columns.js).
492
+ const statusCell = (invoice) => {
493
+ const cell = h('span', {}, invoice.sending
494
+ ? h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Sending')
495
+ : h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute' }, TAB_LABEL[invoice.tab] ?? ''));
496
+ if (!linked) statusCells.set(invoice.id, cell);
497
+ return cell;
498
+ };
499
+ const linkCell = (invoice) => {
500
+ const cell = h('span', { class: 'fx-links' }, systems.filter((s) => s.kind === 'work').map((s) => workIcon(ui, s, invoice.job)));
501
+ statusCells.set(invoice.id, cell);
502
+ return cell;
503
+ };
504
+ const problemCell = (invoice) => {
505
+ const first = invoice.problems?.[0];
506
+ return h('span', { class: 'fx-problem', title: invoice.problems.map((p) => p.message).join(' ') },
507
+ first?.short ?? first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1}`) : null);
508
+ };
509
+ const href = (invoice) => `/invoices/${invoice.id}`;
510
+ const ALL = {
511
+ number: numbered ? { kind: 'number', cell: (i) => link(href(i), i.number ?? 'Draft') } : null,
512
+ client: { kind: 'client', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
513
+ reference: showProblem
514
+ ? { label: 'Problem', kind: 'text', cell: problemCell }
515
+ : { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
516
+ date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) => showDate(i.issueDate ?? i.createdAt?.slice(0, 10)) },
517
+ total: { kind: 'total', num: true, className: 'fx-strong', cell: (i) => formatCents(i.totalCents) },
518
+ status: { kind: 'status', className: 'fx-status-cell', cell: statusCell },
519
+ linked: linked ? { kind: 'link', cell: linkCell } : null,
520
+ };
521
+ available = Object.keys(ALL).filter((id) => ALL[id]);
522
+ const columns = state.columns.columns.filter((id) => ALL[id]).map((id) => ({ id, label: INVOICE_COLUMNS[id], ...ALL[id] }));
523
+ host.replaceChildren(heads.table({
524
+ className: 'fx-table--rows',
525
+ columns,
526
+ lead: [h('th', { class: 'fx-tick-cell' }, all)],
527
+ rows: h('tbody', {}, body.invoices.map((invoice) => h('tr', { tabindex: '0', onclick: () => open(href(invoice)), onkeydown: (event) => { if (event.key === 'Enter') open(href(invoice)); } },
528
+ h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)),
529
+ rowCells(h, columns, invoice),
530
+ h('td', { class: 'fx-c-end' })))),
531
+ }));
425
532
  // Issued and void invoices also show Xero's status, from Flow's copy:
426
533
  // in the Linked column every one gets an icon, so one not in Xero
427
534
  // stands out.
@@ -437,6 +544,7 @@ export async function renderInvoiceList(main, ui) {
437
544
  }
438
545
  await load();
439
546
  if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
547
+ if (returning && back?.frameY) frame.body.scrollTop = back.frameY;
440
548
  }
441
549
 
442
550
  // A file the browser saves, from a blob made on this page.
@@ -499,7 +607,8 @@ function navBar(ui, invoice) {
499
607
  return bar;
500
608
  }
501
609
 
502
- // After a send, an Approve or a Save as draft, the next invoice of the list
610
+ // After a send, an Approve or a Save that makes a Pending invoice Ready, the
611
+ // next invoice of the list
503
612
  // opens; at its end, the list. So a person reviews drafts one after another
504
613
  // without going back. The invoice stays in the bar's list, so the count holds
505
614
  // (2 of 35) and Previous opens it again, now done. Back reloads the list
@@ -595,9 +704,13 @@ function linkedCard(ui, invoice, accountingStatus = null) {
595
704
  facts);
596
705
  }
597
706
 
707
+ // The first sentence of a problem: what is wrong. The red field shows where,
708
+ // so the banner holds no how-to.
709
+ const brief = (text) => String(text ?? '').match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? String(text ?? '');
710
+
598
711
  // A draft, or a new invoice before its first save.
599
712
  function draftView(invoice, ui, context, redraw) {
600
- const { h, flow, toast, go, notice } = ui;
713
+ const { h, flow, toast, go, notice, fieldError } = ui;
601
714
  const isNew = !invoice;
602
715
  const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
603
716
  if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
@@ -636,9 +749,18 @@ function draftView(invoice, ui, context, redraw) {
636
749
  });
637
750
  clientInput.addEventListener('blur', () => { setTimeout(() => { clientList.hidden = true; }, 120); });
638
751
 
639
- const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: 'Practitioner - Patient' });
752
+ const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: 'Type a reference', 'aria-label': 'Reference' });
640
753
  const dueDate = h('input', { class: 'fx-input', type: 'date', value: invoice?.dueDate ?? '' });
641
754
 
755
+ // The fields a failed check reads go red (problem.fields): a rule on the
756
+ // Reference marks the Reference box, a line with no price item marks its
757
+ // item. A check on something this page cannot change (the job's
758
+ // practitioner) marks nothing; the banner says it.
759
+ const problemList = invoice?.problems ?? [];
760
+ const failed = new Set(problemList.flatMap((p) => p.fields ?? []));
761
+ const badLines = new Set(problemList.filter((p) => p.fields?.includes('line_item')).map((p) => p.line));
762
+ const boxes = { reference };
763
+
642
764
  // Lines.
643
765
  let items = null;
644
766
  const itemsReady = flow('/api/invoicing/price-items').then((body) => { items = body.priceItems ?? body.items ?? []; return items; }).catch(() => { items = []; return items; });
@@ -665,9 +787,10 @@ function draftView(invoice, ui, context, redraw) {
665
787
  };
666
788
  showAmount();
667
789
  const item = h('input', {
668
- class: 'fx-input', type: 'text', list: 'fx-price-items', placeholder: 'Code or name', autocomplete: 'off',
790
+ class: 'fx-input', type: 'text', list: 'fx-price-items', placeholder: 'Code', autocomplete: 'off',
669
791
  value: line.itemCode ?? '', 'aria-label': `Item, line ${index + 1}`,
670
792
  });
793
+ if (badLines.has(index + 1)) fieldError(item);
671
794
  const description = h('input', { class: 'fx-input', type: 'text', value: line.description ?? '', 'aria-label': `Description, line ${index + 1}` });
672
795
  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
796
  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 +816,7 @@ function draftView(invoice, ui, context, redraw) {
693
816
  gst.addEventListener('change', () => { line.taxCode = gst.value; preview(); });
694
817
  const remove = h('button', {
695
818
  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(); },
819
+ onclick: () => { badLines.clear(); lines.splice(index, 1); if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); preview(); },
697
820
  }, '×');
698
821
  return h('tr', {},
699
822
  h('td', { class: 'fx-lines__item' }, item),
@@ -734,7 +857,7 @@ function draftView(invoice, ui, context, redraw) {
734
857
  });
735
858
 
736
859
  const check = () => {
737
- if (!contact) return 'Choose a client from the list.';
860
+ if (!contact) { fieldError(clientInput); return 'Choose a client from the list.'; }
738
861
  if (lines.some((line) => (line.description?.trim() || line.priceItemId) && line.unitAmountCents == null)) return 'Each line needs a price.';
739
862
  return null;
740
863
  };
@@ -753,22 +876,38 @@ function draftView(invoice, ui, context, redraw) {
753
876
  }
754
877
  };
755
878
 
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.
879
+ // Save is the check, like CI: it saves, and Flow runs every check again
880
+ // (Flow's own, the lab's rules, the accounting system's). A Pending
881
+ // invoice that now passes is Ready: a toast says so and the next invoice
882
+ // of the list opens. One that still fails stays Pending, with its red
883
+ // fields. A new invoice and a Pending one always offer Save; a Ready one
884
+ // only once something changed. There is no Flow "draft" step: Draft is
885
+ // the accounting system's word for an invoice it holds.
760
886
  const keepsSave = isNew || invoice?.view !== 'ready';
761
887
  const saveButton = h('button', {
762
888
  class: 'fx-btn', type: 'button', hidden: !keepsSave,
763
889
  onclick: async () => {
890
+ saveButton.disabled = true;
764
891
  const saved = await save();
892
+ saveButton.disabled = false;
765
893
  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);
894
+ const passes = saved.view === 'ready';
895
+ const why = brief(saved.problems?.[0]?.message);
896
+ if (isNew) {
897
+ toast(passes ? 'Saved. It is Ready.' : `Saved. It is Pending. ${why}`, passes ? 'success' : 'warn');
898
+ go(`/invoices/${saved.id}`, { replace: true });
899
+ } else if (passes && keepsSave) {
900
+ toast('Saved. It is Ready.', 'success');
901
+ openNext(ui, invoice.id);
902
+ } else if (passes) {
903
+ toast('Saved.', 'success');
904
+ redraw(saved);
905
+ } else {
906
+ toast(`Still Pending. ${why}`, 'danger');
907
+ redraw(saved);
908
+ }
770
909
  },
771
- }, keepsSave ? 'Save as draft' : 'Save changes');
910
+ }, 'Save');
772
911
  const opened = JSON.stringify(body());
773
912
  const changed = () => !isNew && JSON.stringify(body()) !== opened;
774
913
  const watchChanges = () => { if (!keepsSave) saveButton.hidden = !changed(); };
@@ -852,8 +991,16 @@ function draftView(invoice, ui, context, redraw) {
852
991
  const problems = [
853
992
  // Accepting changes the draft for the business: owners and admins only.
854
993
  jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
855
- otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
994
+ otherProblems.length ? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.') : null,
856
995
  ];
996
+ // A red field names the banner as its reason (for a screen reader).
997
+ const banner = problems[1];
998
+ if (banner) banner.id = 'fx-invoice-problem';
999
+ for (const name of failed) {
1000
+ if (!boxes[name]) continue;
1001
+ fieldError(boxes[name]);
1002
+ if (banner) boxes[name].setAttribute('aria-describedby', banner.id);
1003
+ }
857
1004
 
858
1005
  return [
859
1006
  navBar(ui, invoice),