@arcforgelabs/flow 2026.10.37 → 2026.10.39

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 (57) hide show
  1. package/README.md +2 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +27 -9
  5. package/server/api/invoicing.js +198 -51
  6. package/server/app.js +5 -2
  7. package/server/core/alerts.js +1 -1
  8. package/server/core/automations.js +1 -1
  9. package/server/core/custom-fields.js +89 -3
  10. package/server/core/job-board.js +2 -1
  11. package/server/core/job-match.js +241 -0
  12. package/server/core/ledger.js +109 -8
  13. package/server/core/price-list.js +27 -2
  14. package/server/core/read-before-act.js +3 -2
  15. package/server/core/sending.js +36 -18
  16. package/server/db/doctor.js +18 -19
  17. package/server/db/index.js +74 -3
  18. package/server/db/migrate-all.js +3 -4
  19. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  20. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  21. package/server/db/migrations/meta/_journal.json +7 -0
  22. package/server/db/schema.js +14 -0
  23. package/server/integrations/call-guard.js +148 -0
  24. package/server/integrations/port.js +3 -2
  25. package/server/integrations/schema.js +9 -5
  26. package/server/integrations/seazona/feed.js +4 -1
  27. package/server/integrations/seazona/settings.js +8 -4
  28. package/server/integrations/xero/drafts.js +4 -4
  29. package/server/integrations/xero/fit.js +108 -0
  30. package/server/integrations/xero/import.js +6 -1
  31. package/server/integrations/xero/index.js +6 -2
  32. package/server/integrations/xero/ledger.js +3 -0
  33. package/server/integrations/xero/links.js +293 -0
  34. package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
  35. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  36. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  37. package/server/integrations/xero/mirror.js +4 -3
  38. package/server/integrations/xero/reconcile.js +42 -12
  39. package/server/integrations/xero/routes.js +66 -7
  40. package/server/integrations/xero/settings.js +6 -5
  41. package/server/ui/app-page.js +1 -1
  42. package/server/ui/app.js +89 -3
  43. package/server/ui/bulk-bar.js +4 -3
  44. package/server/ui/custom-fields.js +252 -94
  45. package/server/ui/flow.css +326 -29
  46. package/server/ui/invoice-notes.js +2 -2
  47. package/server/ui/invoicing.js +92 -137
  48. package/server/ui/job-board.js +9 -7
  49. package/server/ui/job-change.js +23 -12
  50. package/server/ui/job-view.js +189 -31
  51. package/server/ui/jobs.js +738 -70
  52. package/server/ui/price-list.js +43 -86
  53. package/server/ui/price-match.js +93 -0
  54. package/server/ui/price-reconcile.js +357 -0
  55. package/server/ui/reconcile.js +275 -0
  56. package/server/ui/routes.js +1 -1
  57. package/server/ui/xero-import.js +329 -0
@@ -8,22 +8,22 @@
8
8
  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
+ import { caseNumber } from '/assets/ui/job-board.js';
11
12
  import { jobChangeBanner } from '/assets/ui/job-change.js';
12
13
 
13
- const STATUS_LABEL = { draft: 'Draft', issued: 'Finalised', void: 'Void' };
14
- const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
15
- // Drafts (to check), Problem and Ready are views (server/core/sending.js):
16
- // every draft is in one of them. A person marks a checked draft ready; a
17
- // saved change keeps it there. An issued invoice that failed to send is a
18
- // problem.
19
- // Ready first: it opens by default, as the drafts most often wait there to send.
20
- const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Finalised'], ['void', 'Void']];
21
- const VIEW_LABEL = { problem: 'Problem', ready: 'Ready', sending: 'Sending', to_check: 'Draft' };
22
- const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_check: 'info' };
14
+ // The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
15
+ // person must look), Ready (checks pass), then the accounting system's
16
+ // stages: Draft (a draft there, or on its way), Approved, Sent (to the
17
+ // customer). With no accounting system, an issued invoice is Approved.
18
+ const TABS = [['pending', 'Pending'], ['ready', 'Ready'], ['draft', 'Draft'], ['approved', 'Approved'], ['sent', 'Sent'], ['void', 'Void']];
19
+ const TAB_LABEL = Object.fromEntries(TABS);
20
+ const TAB_TONE = { pending: 'danger', ready: 'success', draft: 'info', approved: 'info', sent: 'success', void: 'mute' };
21
+ // Tab names an earlier Flow kept in this browser.
22
+ const OLD_TAB = { problem: 'pending', to_check: 'ready', issued: 'approved' };
23
+ const NUMBERED = ['draft', 'approved', 'sent', 'void'];
23
24
  const EMPTY = {
24
- to_check: 'No drafts to check. Press New invoice to start one.',
25
- problem: 'No problems.',
26
- ready: 'Nothing is ready. In Drafts, tick drafts and press Ready.',
25
+ pending: 'Nothing needs a look.',
26
+ ready: 'Nothing is ready. Press New invoice to start one.',
27
27
  };
28
28
  // Every tab's rows can be ticked; the bulk bar (bulkActions) says what the
29
29
  // ticked rows can do there.
@@ -111,7 +111,7 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
111
111
 
112
112
  export async function renderInvoiceList(main, ui) {
113
113
  const { h, flow, pageHeader, notice, go, toast } = ui;
114
- const saved = sessionStorage.getItem('flow_invoice_tab');
114
+ const saved = OLD_TAB[sessionStorage.getItem('flow_invoice_tab')] ?? sessionStorage.getItem('flow_invoice_tab');
115
115
  // Back from an invoice comes here with the tab, search and scroll it left.
116
116
  const back = readNav();
117
117
  const returning = sessionStorage.getItem(NAV_BACK) === '1';
@@ -177,20 +177,18 @@ export async function renderInvoiceList(main, ui) {
177
177
  const to = body.sendsTo;
178
178
  const ticked = [...state.ticked];
179
179
  const parts = [];
180
- if (state.status === 'to_check') {
181
- parts.push(h('p', { class: 'fx-dim' }, 'Check each draft, tick it, then press Ready.'));
182
- } else if (state.status === 'ready') {
180
+ if (state.status === 'ready') {
183
181
  parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
184
182
  const buttons = [];
185
183
  if (context.canIssue && body.counts.ready > 0) {
186
- buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Finalise all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
187
- (res) => [`${res.issued} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
184
+ buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Approve all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
185
+ (res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
188
186
  }
189
187
  if (buttons.length) parts.push(h('div', { class: 'fx-sendbar__actions' }, buttons));
190
- } else if (state.status === 'problem') {
191
- const failed = body.invoices.filter((i) => i.status === 'issued').length;
188
+ } else if (state.status === 'pending') {
189
+ const failed = body.invoices.filter((i) => i.status === 'issued' && i.problems.some((p) => p.code === 'send_failed' || p.code?.startsWith('xero_'))).length;
192
190
  if (context.canIssue && to && failed > 0) {
193
- parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, 'finalised invoice', 'finalised invoices')} did not reach ${to}. Fix the cause, then send again.`));
191
+ parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, 'invoice', 'invoices')} did not reach ${to}. Fix the cause, then send again.`));
194
192
  parts.push(action('Send again', 'send-again', () => ({}), (res) => [`${res.queued} on the way to ${to} again.`, 'success']));
195
193
  }
196
194
  }
@@ -225,41 +223,27 @@ export async function renderInvoiceList(main, ui) {
225
223
  function bulkActions(ticked) {
226
224
  const to = body.sendsTo;
227
225
  const rows = body.invoices.filter((i) => state.ticked.has(i.id));
228
- const numbered = ['issued', 'void'].includes(state.status);
226
+ const numbered = NUMBERED.includes(state.status);
229
227
  const status = [];
230
228
  const apply = [];
231
229
  if (state.status === 'ready' && context.canIssue) {
232
230
  status.push({
233
- icon: 'circle-check-big', label: 'Finalise', primary: true,
234
- title: to ? `Finalise and send to ${to}` : 'Finalise',
231
+ icon: to ? 'send' : 'circle-check-big', label: to ? 'Send' : 'Approve', primary: true,
232
+ title: to ? `Send to ${to}` : 'Approve',
235
233
  run: async () => {
236
234
  const ok = await ui.dialog({
237
- title: `Finalise ${plural(ticked.length, 'invoice', 'invoices')}?`,
238
- body: h('p', {}, `Each one gets the next number and is locked.${to ? ` ${sendModeNote(to, body.sendMode, 'each one')}` : ''}`),
239
- confirm: to ? `Finalise and send ${ticked.length}` : `Finalise ${ticked.length}`,
235
+ title: to ? `Send ${plural(ticked.length, 'invoice', 'invoices')} to ${to}?` : `Approve ${plural(ticked.length, 'invoice', 'invoices')}?`,
236
+ body: h('p', {}, to ? sendModeNote(to, body.sendMode, 'each one') : 'Each one gets the next number and is locked.'),
237
+ confirm: to ? `Send ${ticked.length}` : `Approve ${ticked.length}`,
240
238
  });
241
239
  if (!ok) return;
242
240
  await post('send-ready', { issueDate: localDate(), invoiceIds: ticked },
243
- (res) => [`${plural(res.issued, 'invoice', 'invoices')} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, 'success']);
241
+ (res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, 'success']);
244
242
  },
245
243
  });
246
244
  }
247
- if (state.status === 'ready') {
248
- status.push({
249
- icon: 'undo-2', label: 'Drafts', title: 'Move to Drafts',
250
- run: () => post('mark-not-ready', { invoiceIds: ticked }, (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success']),
251
- });
252
- }
253
- if (state.status === 'to_check') {
254
- status.push({
255
- icon: 'check-check', label: 'Ready', primary: true, title: 'Move to Ready',
256
- run: () => post('mark-ready', { invoiceIds: ticked }, (res) => (res.refused.length
257
- ? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
258
- : [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])),
259
- });
260
- }
261
245
  const failed = rows.filter((i) => i.status === 'issued').map((i) => i.id);
262
- if (state.status === 'problem' && context.canIssue && to && failed.length) {
246
+ if (state.status === 'pending' && context.canIssue && to && failed.length) {
263
247
  status.push({
264
248
  icon: 'refresh-cw', label: 'Retry', primary: true, title: `Send again to ${to}`,
265
249
  run: () => post('send-again', { invoiceIds: failed }, (res) => [`${res.queued} on the way to ${to} again.`, 'success']),
@@ -273,7 +257,7 @@ export async function renderInvoiceList(main, ui) {
273
257
  if (numbered) apply.push({ icon: 'file-down', label: 'PDFs', iconOnly: true, title: 'Download PDFs', run: () => downloadPdfs(rows) });
274
258
  const danger = [];
275
259
  const voidable = rows.filter((i) => i.status === 'issued');
276
- if (state.status === 'issued' && context.canIssue && voidable.length) {
260
+ if (['draft', 'approved', 'sent'].includes(state.status) && context.canIssue && voidable.length) {
277
261
  danger.push({
278
262
  icon: 'ban', label: 'Void', iconOnly: true, tone: 'danger', title: 'Void',
279
263
  run: () => voidAll(voidable),
@@ -309,7 +293,7 @@ export async function renderInvoiceList(main, ui) {
309
293
  const cell = (v) => (/[",\n]/.test(String(v ?? '')) ? `"${String(v).replace(/"/g, '""')}"` : String(v ?? ''));
310
294
  const lines = [['Number', 'Client', 'Reference', 'Date', 'Total', 'Status']]
311
295
  .concat(rows.map((i) => [i.number ?? '', i.contactName ?? '', i.reference ?? '', i.issueDate ?? i.createdAt?.slice(0, 10) ?? '',
312
- ((i.totalCents ?? 0) / 100).toFixed(2), i.status === 'void' ? 'Void' : VIEW_LABEL[i.view] ?? STATUS_LABEL[i.status]]));
296
+ ((i.totalCents ?? 0) / 100).toFixed(2), TAB_LABEL[i.tab] ?? '']));
313
297
  save(new Blob([lines.map((l) => l.map(cell).join(',')).join('\r\n')], { type: 'text/csv' }), `invoices-${state.status}-${localDate()}.csv`);
314
298
  toast(`${plural(rows.length, 'invoice', 'invoices')} exported.`, 'success');
315
299
  }
@@ -334,7 +318,7 @@ export async function renderInvoiceList(main, ui) {
334
318
  async function voidAll(rows) {
335
319
  const ok = await ui.dialog({
336
320
  title: `Void ${plural(rows.length, 'invoice', 'invoices')}?`,
337
- body: h('div', {}, h('p', {}, 'Their numbers stay used. A void invoice cannot be finalised again.'),
321
+ body: h('div', {}, h('p', {}, 'Their numbers stay used. A void invoice cannot be used again.'),
338
322
  body.sendsTo ? h('p', { class: 'fx-problem' }, `Flow does not change ${body.sendsTo}: void them in ${body.sendsTo} too.`) : null),
339
323
  confirm: `Void ${rows.length}`, tone: 'danger',
340
324
  });
@@ -394,7 +378,7 @@ export async function renderInvoiceList(main, ui) {
394
378
  type: 'button',
395
379
  role: 'tab',
396
380
  'aria-selected': String(state.status === id),
397
- ...(id === 'problem' && counts?.problem ? { 'data-tone': 'danger' } : {}),
381
+ ...(id === 'pending' && counts?.pending ? { 'data-tone': 'danger' } : {}),
398
382
  onclick: () => { if (state.status !== id) openTab(id); },
399
383
  }, label, h('span', { class: 'fx-tabs__count' }, String(counts?.[id] ?? 0)))));
400
384
  }
@@ -408,17 +392,17 @@ export async function renderInvoiceList(main, ui) {
408
392
  if (!body.invoices.length) {
409
393
  host.replaceChildren(h('p', { class: 'fx-empty' }, state.q
410
394
  ? 'No invoice matches that search.'
411
- : EMPTY[state.status] ?? `No ${STATUS_LABEL[state.status].toLowerCase()} invoices yet.`));
395
+ : EMPTY[state.status] ?? `No ${TAB_LABEL[state.status].toLowerCase()} invoices.`));
412
396
  return;
413
397
  }
414
398
  // The invoice page steps through this list, in this order (navBar).
415
399
  saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
416
400
  const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
417
401
  const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
418
- const showProblem = state.status === 'problem';
419
- const created = ['to_check', 'problem', 'ready'].includes(state.status);
402
+ const showProblem = state.status === 'pending';
403
+ const created = ['pending', 'ready'].includes(state.status);
420
404
  // An invoice gets its number when it is sent or issued, so only the
421
- // Finalised and Void tabs show a Number column; the others open on Client.
405
+ // Numbered tabs show a Number column; the others open on Client.
422
406
  const numbered = !created;
423
407
  // With an integration on, every tab has a Linked column (linkIcon).
424
408
  const systems = body.linksTo ?? [];
@@ -449,25 +433,24 @@ export async function renderInvoiceList(main, ui) {
449
433
  h('thead', {}, h('tr', {},
450
434
  h('th', { class: 'fx-tick-cell' }, all),
451
435
  numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
452
- h('th', {}, created ? 'Created' : 'Finalised'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'),
436
+ h('th', {}, created ? 'Created' : 'Date'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'),
453
437
  linked ? h('th', {}, 'Linked') : null)),
454
438
  h('tbody', {}, body.invoices.map((invoice) => {
455
439
  const href = `/invoices/${invoice.id}`;
456
440
  const first = invoice.problems?.[0];
457
- const view = invoice.status === 'void' ? null : invoice.view;
458
441
  return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
459
442
  h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)),
460
443
  numbered ? h('td', {}, link(href, invoice.number ?? 'Draft')) : null,
461
444
  h('td', {}, numbered ? (invoice.contactName ?? '—') : link(href, invoice.contactName ?? 'No client')),
462
445
  showProblem
463
- ? h('td', { class: 'fx-problem' }, first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1} more`) : null)
446
+ ? 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)
464
447
  : h('td', { class: 'fx-dim' }, invoice.reference ?? '—'),
465
448
  h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
466
449
  h('td', { class: 'fx-num fx-strong' }, formatCents(invoice.totalCents)),
467
450
  (() => {
468
- const cell = h('td', { class: 'fx-status-cell' }, view
469
- ? h('span', { class: 'fx-status', 'data-tone': VIEW_TONE[view] }, VIEW_LABEL[view])
470
- : h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status]));
451
+ const cell = h('td', { class: 'fx-status-cell' }, invoice.sending
452
+ ? h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Sending')
453
+ : h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute' }, TAB_LABEL[invoice.tab] ?? ''));
471
454
  if (!linked) statusCells.set(invoice.id, cell);
472
455
  return cell;
473
456
  })(),
@@ -477,7 +460,7 @@ export async function renderInvoiceList(main, ui) {
477
460
  return cell;
478
461
  })() : null);
479
462
  }))));
480
- // Finalised and void invoices also show Xero's status, from Flow's copy:
463
+ // Issued and void invoices also show Xero's status, from Flow's copy:
481
464
  // in the Linked column every one gets an icon, so one not in Xero
482
465
  // stands out.
483
466
  if (numbered) {
@@ -519,8 +502,7 @@ function saveNav(nav) {
519
502
 
520
503
  // The bar above one invoice: Back to the list as it was, and the previous and
521
504
  // next invoice of that list. An invoice opened from elsewhere (the job board,
522
- // search, a link) steps through its own tab: Drafts, Ready, Problem, Finalised
523
- // or Void, read once here.
505
+ // search, a link) steps through its own tab, read once here.
524
506
  function navBar(ui, invoice) {
525
507
  const { h, go, flow } = ui;
526
508
  const id = invoice?.id ?? null;
@@ -543,7 +525,7 @@ function navBar(ui, invoice) {
543
525
  h('span', { class: 'fx-pager__steps' }, step(ids[at - 1], 'Previous invoice', 'M14.5 6l-6 6 6 6'), step(ids[at + 1], 'Next invoice', 'M9.5 6l6 6-6 6')));
544
526
  return true;
545
527
  };
546
- const tab = invoice?.status === 'void' ? 'void' : invoice?.view ?? invoice?.status;
528
+ const tab = invoice?.tab ?? null;
547
529
  if (id && !fill(readNav()?.ids ?? []) && TABS.some(([t]) => t === tab)) {
548
530
  flow(`/api/invoicing/invoices?${new URLSearchParams({ status: tab })}`).then((body) => {
549
531
  const ids = body.invoices.map((i) => i.id);
@@ -555,7 +537,7 @@ function navBar(ui, invoice) {
555
537
  return bar;
556
538
  }
557
539
 
558
- // After a send, a Finalise or a Save as draft, the next invoice of the list
540
+ // After a send, an Approve or a Save as draft, the next invoice of the list
559
541
  // opens; at its end, the list. So a person reviews drafts one after another
560
542
  // without going back. The invoice stays in the bar's list, so the count holds
561
543
  // (2 of 35) and Previous opens it again, now done. Back reloads the list
@@ -605,6 +587,10 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
605
587
  h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
606
588
  }
607
589
 
590
+ // The job's number a person reads: its case number, else its order number.
591
+ // A system's own record id (a UUID) is never shown (job-board.js).
592
+ const jobRef = (job) => caseNumber(job) ?? (job.sourceInvoiceRef ? `#${job.sourceInvoiceRef}` : null);
593
+
608
594
  // The Linked card: this invoice's link in each connected system (linksTo).
609
595
  // A work system row names the job's order and opens it there; the
610
596
  // accounting row gives the number and state there (from Flow's copy). A
@@ -628,7 +614,7 @@ function linkedCard(ui, invoice, accountingStatus = null) {
628
614
  const rows = systems.map((system) => {
629
615
  if (system.kind === 'work') {
630
616
  return job?.sourceSystem === system.id
631
- ? row(system, { tone: 'success', ref: job.caseRef ?? job.sourceJobRef, url: job.link?.url ?? null })
617
+ ? row(system, { tone: 'success', ref: jobRef(job), url: job.link?.url ?? null })
632
618
  : row(system, { none: 'No job linked' });
633
619
  }
634
620
  const status = accountingStatus;
@@ -654,7 +640,7 @@ function draftView(invoice, ui, context, redraw) {
654
640
  const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
655
641
  if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
656
642
  let contact = invoice?.contact ?? null;
657
- // Finalise invoice waits for a client and a due date (syncIssue, below).
643
+ // Approve waits for a client and a due date (syncIssue, below).
658
644
  let onContact = () => {};
659
645
 
660
646
  // Client: type to find a customer.
@@ -826,7 +812,7 @@ function draftView(invoice, ui, context, redraw) {
826
812
  const watchChanges = () => { if (!keepsSave) saveButton.hidden = !changed(); };
827
813
  // With an accounting system on, a ready invoice is sent there: Flow issues
828
814
  // it and queues it, the same as Send all ready. With none, Flow issues it
829
- // and keeps it. A draft that is not ready has no send: move it to Ready first.
815
+ // and keeps it. A Pending draft has no send: fix its problem first.
830
816
  const to = invoice?.sendsTo ?? context.sendsTo ?? null;
831
817
  const ready = invoice?.view === 'ready';
832
818
  const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
@@ -851,31 +837,31 @@ function draftView(invoice, ui, context, redraw) {
851
837
  }
852
838
  },
853
839
  }, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
854
- // With no accounting system, Finalise invoice gives the draft its number and
855
- // locks it. A changed job blocks it, as it blocks Move to Ready.
840
+ // With no accounting system, Approve gives the draft its number and locks
841
+ // it. A changed job blocks it.
856
842
  const issueButton = context.canIssue && !to ? h('button', {
857
843
  class: 'fx-btn fx-btn--primary', type: 'button',
858
844
  onclick: async () => {
859
845
  const saved = await save();
860
846
  if (!saved) return;
861
847
  const ok = await ui.dialog({
862
- title: 'Finalise this invoice?',
848
+ title: 'Approve this invoice?',
863
849
  body: h('p', {}, `It gets the next number (${nextNumber}). Flow keeps it exactly as issued. To change it later, revise it.`),
864
- confirm: 'Finalise',
850
+ confirm: 'Approve',
865
851
  });
866
852
  if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
867
853
  try {
868
854
  const { invoice: issued } = await flow(`/api/invoicing/invoices/${saved.id}/issue`, { method: 'POST', body: JSON.stringify({ issueDate: localDate() }) });
869
- toast(`${issued.number} is final.`, 'success');
855
+ toast(`${issued.number} approved.`, 'success');
870
856
  if (isNew) go(`/invoices/${issued.id}`, { replace: true });
871
857
  else openNext(ui, invoice.id);
872
858
  } catch (error) {
873
859
  toast(error.message, 'danger');
874
860
  }
875
861
  },
876
- }, 'Finalise') : null;
862
+ }, 'Approve') : null;
877
863
  // Issue needs a client and a due date: until both are set it is greyed.
878
- // A changed job greys it too, as it does Move to Ready.
864
+ // A changed job greys it too.
879
865
  const syncIssue = () => {
880
866
  if (!issueButton) return;
881
867
  const missing = [contact ? null : 'a client', dueDate.value ? null : 'a due date'].filter(Boolean);
@@ -898,74 +884,41 @@ function draftView(invoice, ui, context, redraw) {
898
884
  h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
899
885
  h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
900
886
 
901
- // Finalise, with an accounting system on: the draft is saved, then Flow
902
- // checks it and moves it to the Ready tab, for Send all ready; nothing is
903
- // sent here. A draft the draft routine made from a job ready to invoice is
904
- // in Ready already (automations.js), so this is for the rest. One with a
905
- // problem stays a draft. Move to Drafts, on the Status card, undoes it. A
906
- // changed job is fixed at its source or adopted, so the button waits.
887
+ // A draft with no problem is in Ready by itself; one with a problem is
888
+ // Pending. A changed job is fixed at its source or accepted here.
907
889
  const jobChange = invoice?.jobChange ?? null;
908
- const markReadyButton = isNew || ready || !to ? null : h('button', {
909
- class: 'fx-btn fx-btn--primary', type: 'button', disabled: Boolean(jobChange),
910
- title: jobChange ? 'Fix the problem first.' : `Checks this draft and moves it to Ready. Send all ready sends it to ${to}.`,
911
- onclick: async () => {
912
- if (!(await save())) return;
913
- try {
914
- const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
915
- if (res.marked) {
916
- toast(`Final. It is in Ready; Send all ready sends it to ${to}.`, 'success');
917
- openNext(ui, invoice.id);
918
- return;
919
- }
920
- toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
921
- const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
922
- redraw(fresh);
923
- } catch (error) {
924
- toast(error.message, 'danger');
925
- }
926
- },
927
- }, 'Finalise');
928
- // A ready invoice can go back to Drafts. It is a status change, so it sits
929
- // with the status, not with the page's main buttons.
930
- const statusCard = ready ? h('section', { class: 'fx-card fx-side-card' },
931
- h('h3', { class: 'fx-label' }, 'Status'),
932
- h('p', {}, 'Ready to send.'),
933
- h('button', {
934
- class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button',
935
- onclick: async () => {
936
- try {
937
- await flow('/api/invoicing/invoices/mark-not-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
938
- toast('Moved to Drafts.', 'success');
939
- const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
940
- redraw(fresh);
941
- } catch (error) {
942
- toast(error.message, 'danger');
943
- }
944
- },
945
- }, 'Move to Drafts')) : null;
946
890
  // The job changed after the invoice was made: its own banner shows what
947
891
  // changed. Other problems stay one line.
948
892
  const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
949
893
  const problems = [
950
894
  jobChange ? jobChangeBanner(ui, jobChange, {
951
895
  money: formatCents,
952
- // Adopting changes the draft for the business: owners and admins only.
953
- onAdopt: !context.canIssue ? null : async () => {
896
+ // Accepting changes the draft for the business: owners and admins only.
897
+ onAccept: !context.canIssue ? null : async () => {
954
898
  const system = jobChange.systemName;
955
- const ok = await ui.dialog({
956
- title: `Adopt the ${system} changes?`,
957
- body: h('p', {}, `This invoice takes the Now in ${system} column.${changed() ? ' Your unsaved changes here are lost.' : ''} Nothing goes to ${system} or the accounting system.`),
958
- confirm: 'Adopt changes',
959
- });
960
- if (!ok) return;
899
+ const lost = [jobChange.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, changed() ? 'Unsaved changes are lost.' : null].filter(Boolean);
900
+ if (lost.length) {
901
+ const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
902
+ if (!ok) return;
903
+ }
961
904
  try {
962
- const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body: '{}' });
963
- toast(`The invoice now matches ${system}.${res.invoice.view === 'ready' ? ' It is in Ready.' : ''}`, 'success');
905
+ const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
906
+ toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
964
907
  redraw(res.invoice);
965
908
  } catch (error) {
966
909
  toast(error.message, 'danger');
967
910
  }
968
911
  },
912
+ // Reads this one job from the work system again, then every check runs.
913
+ onRecheck: async () => {
914
+ try {
915
+ const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/recheck`, { method: 'POST', body: '{}' });
916
+ toast(res.invoice?.jobChange ? `Still changed in ${jobChange.systemName}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
917
+ if (res.invoice) redraw(res.invoice);
918
+ } catch (error) {
919
+ toast(error.message, 'danger');
920
+ }
921
+ },
969
922
  }) : null,
970
923
  otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
971
924
  ];
@@ -974,9 +927,9 @@ function draftView(invoice, ui, context, redraw) {
974
927
  navBar(ui, invoice),
975
928
  h('header', { class: 'fx-ph' },
976
929
  h('div', { class: 'fx-ph__text' },
977
- h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
930
+ h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice'),
978
931
  h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
979
- h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, markReadyButton, sendButton ?? issueButton)),
932
+ h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton)),
980
933
  ...problems,
981
934
  fromJob,
982
935
  h('div', { class: 'fx-invoice__grid' },
@@ -996,7 +949,7 @@ function draftView(invoice, ui, context, redraw) {
996
949
  h('div', { class: 'fx-lines__foot' },
997
950
  h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); } }, '+ Add line'),
998
951
  totalsHost))),
999
- h('aside', { class: 'fx-invoice__aside' }, statusCard, linkedCard(ui, invoice))),
952
+ h('aside', { class: 'fx-invoice__aside' }, linkedCard(ui, invoice))),
1000
953
  ].map((part) => {
1001
954
  // Any edit on the page shows Save changes; undoing it hides it again.
1002
955
  for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
@@ -1070,7 +1023,7 @@ function issuedView(invoice, ui, context, redraw) {
1070
1023
  const voidBody = () => {
1071
1024
  const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
1072
1025
  return h('div', {},
1073
- h('p', {}, 'The number stays used. A void invoice cannot be finalised again.'),
1026
+ h('p', {}, 'The number stays used. A void invoice cannot be used again.'),
1074
1027
  live ? h('p', { class: 'fx-problem' }, `Xero still holds it as ${xeroStatus.label.replace(/ in Xero$/, '').toLowerCase()}${xeroStatus.xero.number ? ` (${xeroStatus.xero.number})` : ''}. Flow does not change Xero: void it in Xero too.`) : null);
1075
1028
  };
1076
1029
  const actions = invoice.status === 'issued' && context.canIssue ? [
@@ -1086,8 +1039,8 @@ function issuedView(invoice, ui, context, redraw) {
1086
1039
  h('div', { class: 'fx-ph__text' },
1087
1040
  h('div', { class: 'fx-row' },
1088
1041
  h('h1', { class: 'fx-title' }, invoice.number),
1089
- h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])),
1090
- h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · finalised ${showDate(invoice.issueDate)}`)),
1042
+ h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
1043
+ h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · ${showDate(invoice.issueDate)}`)),
1091
1044
  h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
1092
1045
  h('div', { class: 'fx-invoice__grid' },
1093
1046
  h('div', { class: 'fx-invoice__main' },
@@ -1095,7 +1048,7 @@ function issuedView(invoice, ui, context, redraw) {
1095
1048
  h('dl', { class: 'fx-doc__meta' },
1096
1049
  h('div', {}, h('dt', { class: 'fx-label' }, 'Bill to'), h('dd', {}, invoice.contact?.name ?? '—', invoice.contact?.email ? h('span', { class: 'fx-dim' }, invoice.contact.email) : null)),
1097
1050
  h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
1098
- h('div', {}, h('dt', { class: 'fx-label' }, 'Finalised'), h('dd', {}, showDate(invoice.issueDate))),
1051
+ h('div', {}, h('dt', { class: 'fx-label' }, 'Date'), h('dd', {}, showDate(invoice.issueDate))),
1099
1052
  h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
1100
1053
  h('table', { class: 'fx-lines__table fx-lines__table--read' },
1101
1054
  h('thead', {}, h('tr', {}, h('th', {}, 'Description'), h('th', { class: 'fx-num' }, 'Qty'), h('th', { class: 'fx-num' }, 'Unit price'), h('th', {}, 'Tax'), h('th', { class: 'fx-num' }, 'Amount'))),
@@ -1112,7 +1065,7 @@ function issuedView(invoice, ui, context, redraw) {
1112
1065
  invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
1113
1066
  h('h3', { class: 'fx-label' }, 'History'),
1114
1067
  h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
1115
- h('span', { class: 'fx-strong' }, v.version === 1 ? 'Finalised' : `Revised (version ${v.version})`),
1068
+ h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
1116
1069
  h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
1117
1070
  ];
1118
1071
  }
@@ -1138,8 +1091,9 @@ function xeroCard(invoice, ui, context, status, reload) {
1138
1091
  toast(res.resolved ? 'Flow and Xero agree now.' : 'Still different. Fix it in Xero or in Flow, then try again.', res.resolved ? 'success' : 'danger');
1139
1092
  await reload();
1140
1093
  } catch (error) {
1141
- toast(error.message, 'danger');
1094
+ toast(error.message, error.status === 429 ? 'info' : 'danger');
1142
1095
  resolve.disabled = false;
1096
+ ui.holdButton(resolve, error.retryAfterMs);
1143
1097
  }
1144
1098
  },
1145
1099
  }, 'Mark resolved') : null;
@@ -1179,9 +1133,10 @@ function sendToXero(invoice, ui, context) {
1179
1133
  sent = ['created', 'reconciled', 'existing', 'already_in_xero'].includes(result?.outcome);
1180
1134
  } catch (error) {
1181
1135
  toast(error.message, 'danger');
1136
+ if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
1182
1137
  } finally {
1183
1138
  if (sent) showSent(button);
1184
- else button.disabled = false;
1139
+ else if (!button.dataset.label) button.disabled = false;
1185
1140
  }
1186
1141
  },
1187
1142
  }, 'Send to Xero');
@@ -42,7 +42,7 @@ export function orderStatuses(statuses = [], jobs = []) {
42
42
  }
43
43
 
44
44
  export const INVOICE_STAGES = {
45
- drafted: 'Drafted', problem: 'Problem', ready: 'Ready to invoice', invoiced: 'Invoiced', paid: 'Paid',
45
+ drafted: 'Drafted', problem: 'Pending', ready: 'Ready to invoice', invoiced: 'Invoiced', paid: 'Paid',
46
46
  };
47
47
 
48
48
  const PROBLEM_VIEWS = ['pending', 'review_required', 'held'];
@@ -97,9 +97,9 @@ function reasonFix(reason) {
97
97
  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
98
  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
99
  // The matcher found invoices that may be this job's, but no exact link.
100
- // Flow holds the job so that it is not invoiced two times.
100
+ // A person makes an invoice or links one (jobs.js draws the two buttons).
101
101
  if (/^the accounting evidence is ambiguous/.test(reason)) {
102
- return { label: 'Invoice unclear', text: 'Flow cannot tell if this job has an invoice already.', todo: 'Check the invoice number on the job in the work system. Flow holds the job so it is not invoiced two times.', go: 'work' };
102
+ return { label: 'No invoice', text: 'This job has no invoice.', todo: null, go: 'choose' };
103
103
  }
104
104
  if (/^the accounting evidence is conflict/.test(reason)) {
105
105
  return { label: 'Invoice link changed', text: 'A second invoice now answers for this job, or its invoice is gone.', todo: 'Check the invoice number on the job in the work system. Void an extra invoice.', go: 'work' };
@@ -133,11 +133,13 @@ function draftFix(problem) {
133
133
  return { label: 'Invoice problem', text: 'The invoice for this job needs a fix before it can be sent.', todo: 'Open the invoice for the steps.', go: 'invoice' };
134
134
  }
135
135
 
136
- // Where a fix's link goes: Flow's invoice, the job in the work system, or a
137
- // Flow settings page (jobs.js draws the link).
138
- export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations'];
136
+ // 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'];
139
140
 
140
- // What a person must fix on a job, in order: [{ label, text, todo, go }].
141
+ // What a person must fix on a job, in order: [{ label, text, todo, go }];
142
+ // `todo` is null when the buttons are the instruction.
141
143
  // Empty unless invoiceStage is Problem.
142
144
  export function fixes(o) {
143
145
  if (invoiceStage(o) !== 'problem') return [];
@@ -1,8 +1,9 @@
1
1
  // The "job changed" problem on a draft invoice (server/core/ledger.js,
2
2
  // jobChangeFor): what the job was when the invoice was made and what the
3
3
  // work system has now, one row per changed fact, with "Open in <system>" to
4
- // fix it there and "Adopt <system> changes" to take the new values into
5
- // Flow's own draft. Flow never writes to the work system. Kept apart from
4
+ // fix it there, "Accept change" to take the new values into Flow's own draft,
5
+ // and a recheck button that reads the job from the work system again. Flow
6
+ // never writes to the work system. Kept apart from
6
7
  // invoicing.js so tests can load it without a browser.
7
8
 
8
9
  // Rows shown before "Show all".
@@ -35,14 +36,14 @@ export function changeRows(change, money) {
35
36
  export function changeSummary(change, rowCount) {
36
37
  const system = change.systemName ?? 'the work system';
37
38
  const count = `${rowCount} ${rowCount === 1 ? 'change' : 'changes'}.`;
38
- if (change.canAdopt) return `The job changed in ${system} after this invoice was made. ${count}`;
39
- if (change.sent) return `The job changed in ${system} after this invoice was sent. ${count} Fix it in ${system} or in the accounting system.`;
40
- return `The job changed in ${system}, and this draft changed too. ${count} Fix it in ${system} or in this draft.`;
39
+ if (change.canAdopt) return `Changed in ${system}. ${count}`;
40
+ if (change.sent) return `Changed in ${system} after sending. ${count}`;
41
+ return `Changed in ${system} and here. ${count}`;
41
42
  }
42
43
 
43
- // The banner. `onAdopt` runs "Adopt changes"; it is shown only when only the
44
- // work system changed and the draft is not sent (ledger.js, outcome offer).
45
- export function jobChangeBanner(ui, change, { money, onAdopt = null }) {
44
+ // The banner. `onAccept` runs "Accept change"; it is shown while the draft
45
+ // is not sent (ledger.js, canAccept). `onRecheck` reads the job again.
46
+ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck = null }) {
46
47
  const { h } = ui;
47
48
  const system = change.systemName ?? 'the work system';
48
49
  const rows = changeRows(change, money);
@@ -53,8 +54,19 @@ export function jobChangeBanner(ui, change, { money, onAdopt = null }) {
53
54
  title: change.link.exact ? `Opens this job in ${system}.` : `Opens ${system}. Find ${order ?? 'the job'} there.`,
54
55
  }, `Open in ${system} ↗`)
55
56
  : null;
56
- const adopt = change.canAdopt && onAdopt
57
- ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', onclick: onAdopt }, `Adopt ${system} changes`)
57
+ const accept = change.canAccept && onAccept
58
+ ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', title: `This invoice takes the Now in ${system} column.`, onclick: onAccept }, 'Accept change')
59
+ : null;
60
+ const recheck = onRecheck
61
+ ? h('button', {
62
+ class: 'fx-btn fx-btn--sm fx-btn--icon fx-change__recheck', type: 'button', title: `Check ${system} again`, 'aria-label': `Check ${system} again`,
63
+ onclick: async (event) => {
64
+ const button = event.currentTarget;
65
+ button.disabled = true;
66
+ button.classList.add('is-busy');
67
+ try { await onRecheck(); } finally { button.disabled = false; button.classList.remove('is-busy'); }
68
+ },
69
+ }, h('span', { class: 'fx-lucide', style: '--icon: url(/assets/icons/refresh-cw.svg)', 'aria-hidden': 'true' }))
58
70
  : null;
59
71
  const body = h('tbody', {});
60
72
  const draw = (all) => body.replaceChildren(...(all ? rows : rows.slice(0, FIRST_ROWS)).map((row) => h('tr', {},
@@ -68,8 +80,7 @@ export function jobChangeBanner(ui, change, { money, onAdopt = null }) {
68
80
  return h('section', { class: 'fx-change', role: 'alert' },
69
81
  h('div', { class: 'fx-change__head' },
70
82
  h('p', {}, h('strong', {}, 'Problem.'), ' ', changeSummary(change, rows.filter((row) => row.field !== 'Total').length)),
71
- h('div', { class: 'fx-change__acts' }, link, adopt)),
72
- adopt ? h('p', { class: 'fx-change__note' }, `Adopt: this invoice takes the Now in ${system} column. Nothing goes to ${system} or the accounting system.`) : null,
83
+ h('div', { class: 'fx-change__acts' }, link, accept, recheck)),
73
84
  !change.link && order ? h('p', { class: 'fx-change__note' }, `Find ${order} in ${system} to fix it there.`) : null,
74
85
  rows.length ? h('div', { class: 'fx-change__scroll' }, h('table', { class: 'fx-change__table' },
75
86
  h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, 'Field'), h('th', { scope: 'col' }, 'Was'), h('th', { scope: 'col' }, `Now in ${system}`))),