@arcforgelabs/flow 2026.10.43 → 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.
@@ -10,7 +10,11 @@ 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 { 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(); };
@@ -852,8 +965,16 @@ function draftView(invoice, ui, context, redraw) {
852
965
  const problems = [
853
966
  // Accepting changes the draft for the business: owners and admins only.
854
967
  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,
968
+ otherProblems.length ? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.') : null,
856
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
+ }
857
978
 
858
979
  return [
859
980
  navBar(ui, invoice),