@arcforgelabs/flow 2026.10.46 → 2026.10.48

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 (63) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +2 -2
  4. package/package.json +1 -1
  5. package/server/api/invoicing.js +89 -22
  6. package/server/auth/people.js +3 -1
  7. package/server/auth/routes.js +4 -1
  8. package/server/core/contacts.js +160 -0
  9. package/server/core/directory.js +4 -9
  10. package/server/core/invoice-rules.js +34 -4
  11. package/server/core/invoices.js +93 -1
  12. package/server/core/invoicing-rules.js +20 -3
  13. package/server/core/job-board.js +1 -1
  14. package/server/core/sending.js +141 -25
  15. package/server/db/doctor.js +1 -1
  16. package/server/db/index.js +19 -0
  17. package/server/db/migrations/0021_flow_schema.sql +68 -0
  18. package/server/db/migrations/0022_contacts.sql +11 -0
  19. package/server/db/migrations/meta/0021_snapshot.json +4377 -0
  20. package/server/db/migrations/meta/0022_snapshot.json +4446 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +15 -0
  23. package/server/integrations/port.js +2 -0
  24. package/server/integrations/schema.js +3 -2
  25. package/server/integrations/sentry/index.js +14 -7
  26. package/server/integrations/setup.js +1 -0
  27. package/server/integrations/xero/catalogue.js +82 -14
  28. package/server/integrations/xero/client.js +37 -0
  29. package/server/integrations/xero/create-contact.js +126 -0
  30. package/server/integrations/xero/delete-invoice.js +104 -0
  31. package/server/integrations/xero/drafts.js +19 -8
  32. package/server/integrations/xero/index.js +9 -0
  33. package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
  34. package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
  35. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  36. package/server/integrations/xero/routes.js +21 -0
  37. package/server/integrations/xero/settings.js +3 -0
  38. package/server/ui/alerts.js +2 -1
  39. package/server/ui/app.js +113 -28
  40. package/server/ui/bulk-bar.js +18 -5
  41. package/server/ui/columns.js +5 -2
  42. package/server/ui/contacts.js +260 -0
  43. package/server/ui/custom-fields.js +22 -7
  44. package/server/ui/flow.css +275 -24
  45. package/server/ui/invoicing.js +374 -70
  46. package/server/ui/job-board.js +10 -0
  47. package/server/ui/job-change.js +4 -3
  48. package/server/ui/job-view.js +3 -1
  49. package/server/ui/jobs.js +150 -19
  50. package/server/ui/kit.js +220 -45
  51. package/server/ui/list-views.js +50 -10
  52. package/server/ui/mobile.js +729 -0
  53. package/server/ui/monitoring.js +51 -0
  54. package/server/ui/nav-pins.js +44 -0
  55. package/server/ui/pager.js +57 -10
  56. package/server/ui/price-list.js +1 -1
  57. package/server/ui/price-reconcile.js +12 -12
  58. package/server/ui/reconcile.js +18 -17
  59. package/server/ui/routes.js +1 -1
  60. package/server/ui/saved-views.js +2 -1
  61. package/server/ui/sidecar.js +106 -6
  62. package/server/ui/view-bar.js +22 -11
  63. package/server/ui/xero-import.js +48 -19
@@ -10,10 +10,13 @@ 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 { rowMenu } from '/assets/ui/kit.js';
14
+ import { isPhone, WIDTHS } from '/assets/ui/mobile.js';
15
+ import { recordBar, watchPhonePrimary } from '/assets/ui/sidecar.js';
13
16
  import { columnTable, dateCell, fieldColumn, rowCells, viewSaver } from '/assets/ui/columns.js';
14
- import { fieldGroup, filterCount, filterJobs, INVOICE_COLUMNS, invoiceColumnsView } 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 { fieldGroup, fieldIdOf, filterCount, filterJobs, INVOICE_COLUMNS, invoiceColumnsView, KIND_OF_TYPE } from '/assets/ui/job-view.js';
18
+ import { INVOICE_FILTER_FIELDS, INVOICE_FILTER_STATUSES, invoiceFilterKind, invoiceKindOf, invoiceValue, normaliseInvoicesView } from '/assets/ui/list-views.js';
19
+ import { listPager, pager, PAGE_SIZES } from '/assets/ui/pager.js';
17
20
  import { savedViews } from '/assets/ui/saved-views.js';
18
21
  import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
19
22
 
@@ -119,13 +122,14 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
119
122
  // views with them and filters every invoice of a tab (GET /invoices).
120
123
  const FILTER_FIELDS = INVOICE_FILTER_FIELDS;
121
124
  const PROBLEM_OPS = { yes: 'Has one', no: 'Has none' };
125
+ const STATUS_OPS = { is: 'Is', not: 'Is not' };
122
126
  const FILTER_KEY = 'flow_invoice_filters';
123
127
  const SIZE_KEY = 'flow_invoice_page_size';
124
- const filterKind = invoiceFilterKind;
125
128
  export { invoiceValue };
126
129
 
127
- // The invoices a filter view keeps.
128
- export const keptInvoices = (invoices, view, now = today()) => filterJobs(invoices, view, { valueOf: invoiceValue, kindOf: filterKind, today: now });
130
+ // The invoices a filter view keeps. `kindOf` knows the lab's custom fields;
131
+ // `me` is the person looking, for "Me" on a person field.
132
+ export const keptInvoices = (invoices, view, now = today(), { kindOf = invoiceFilterKind, me = null } = {}) => filterJobs(invoices, view, { valueOf: invoiceValue, kindOf, late: null, me, today: now });
129
133
 
130
134
  // Rows per page, kept on this device.
131
135
  function readSize() {
@@ -194,11 +198,27 @@ export async function renderInvoiceList(main, ui) {
194
198
  const opening = views.opening();
195
199
  if (opening) state.view = normaliseInvoicesView(opening);
196
200
  views.draw();
201
+ // The same fields the Columns menu offers: the invoice's columns, its
202
+ // job's columns, then the lab's custom fields (view-bar.js draws them).
203
+ const fieldById = (id) => state.fields.find((f) => f.id === id) ?? null;
204
+ const kindOf = (field) => invoiceKindOf(state.fields)(field);
205
+ const labels = (list) => (list ?? []).filter(Boolean).map((v) => ({ value: v, label: v }));
197
206
  const filterMenu = viewBar(h).filterMenu({
198
- fields: () => Object.entries(FILTER_FIELDS).map(([value, f]) => ({ value, label: f.label, icon: f.icon })),
199
- kindOf: filterKind,
200
- opsOf: (field) => (field === 'problem' ? PROBLEM_OPS : null),
201
- choicesOf: (field) => (field === 'client' ? (raw?.clients ?? []).map((v) => ({ value: v, label: v })) : []),
207
+ fields: () => [
208
+ ...Object.entries(FILTER_FIELDS).map(([value, f]) => ({ value, label: f.label, icon: f.icon })),
209
+ { group: 'Custom fields', options: state.fields.filter((f) => KIND_OF_TYPE[f.type]).map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
210
+ ],
211
+ kindOf,
212
+ opsOf: (field) => (field === 'problem' ? PROBLEM_OPS : field === 'status' ? STATUS_OPS : null),
213
+ choicesOf: (field) => {
214
+ if (field === 'client') return labels(raw?.clients);
215
+ if (field === 'status') return INVOICE_FILTER_STATUSES;
216
+ if (field === 'linked') return (raw?.linksTo ?? []).filter((s) => s.kind === 'work').map((s) => ({ value: s.id, label: s.name }));
217
+ if (field === 'practitioner') return labels(raw?.practitioners);
218
+ const custom = fieldById(fieldIdOf(field));
219
+ if (custom?.type === 'person') return [{ value: 'me', label: 'Me' }, ...state.people.map((p) => ({ value: p.id, label: p.name }))];
220
+ return (custom?.options ?? []).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name, color: o.color }));
221
+ },
202
222
  view: () => state.view,
203
223
  change: setView,
204
224
  first: 'client',
@@ -230,6 +250,9 @@ export async function renderInvoiceList(main, ui) {
230
250
  state.values = new Map((values?.values ?? []).map((v) => [valueKey(v, v.fieldId), v.value]));
231
251
  let body = null;
232
252
  let raw = null;
253
+ // Xero's status from Flow's copy, filled after the list draws. Delete on
254
+ // an issued invoice waits for this: it shows only while the copy says Draft.
255
+ let xeroById = new Map();
233
256
  const openTab = (id) => { state.status = id; state.offset = 0; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
234
257
  const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
235
258
  // The tabs and search stay at the top while the rows scroll up under them;
@@ -259,17 +282,20 @@ export async function renderInvoiceList(main, ui) {
259
282
  }
260
283
 
261
284
  // A bulk step on the server: on success the ticks clear and the list loads
262
- // again; on a failure the ticks stay, so a person can try again.
263
- async function post(path, payload, done) {
285
+ // again (or `then(res)` names the tab the invoices went to, and it opens);
286
+ // on a failure the ticks stay, so a person can try again.
287
+ async function post(path, payload, done, then = () => null) {
264
288
  try {
265
289
  const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload) });
266
290
  toast(...done(res));
267
291
  state.ticked.clear();
268
292
  seen.clear();
269
- await load();
293
+ const tab = then(res);
294
+ if (tab && tab !== state.status) openTab(tab);
295
+ else await load();
270
296
  alerts.refresh();
271
297
  } catch (error) {
272
- toast(error.message, 'danger');
298
+ toast(error.message, 'danger', { stay: true });
273
299
  }
274
300
  }
275
301
 
@@ -285,15 +311,25 @@ export async function renderInvoiceList(main, ui) {
285
311
  status.push({
286
312
  icon: to ? 'send' : 'circle-check-big', label: to ? `Send to ${to}` : 'Approve', primary: true,
287
313
  title: to ? `Send to ${to}` : 'Approve',
288
- run: async () => {
314
+ run: async (working) => {
289
315
  const ok = await ui.dialog({
290
316
  title: to ? `Send ${plural(ticked.length, 'invoice', 'invoices')} to ${to}?` : `Approve ${plural(ticked.length, 'invoice', 'invoices')}?`,
291
317
  body: h('p', {}, to ? sendModeNote(to, body.sendMode, 'each one') : 'Each one gets the next number and is locked.'),
292
318
  confirm: to ? `Send ${ticked.length}` : `Approve ${ticked.length}`,
293
319
  });
294
320
  if (!ok) return;
295
- await post('send-ready', { issueDate: localDate(), invoiceIds: ticked },
296
- (res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.held ? ` ${res.held} failed a check and went to Pending.` : ''}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, res.held ? 'info' : 'success']);
321
+ working(to ? 'Sending' : 'Approving');
322
+ // The sent ones show on Draft as Sending until the accounting
323
+ // system has them; the approved ones on Approved.
324
+ await post('send-ready', { issueDate: localDate(), invoiceIds: ticked }, (res) => {
325
+ const sent = to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`;
326
+ const notes = [
327
+ res.held ? `${res.held} failed a check and went to Pending.` : '',
328
+ res.waiting ? `${res.waiting} could not be confirmed with the work system yet: press Send again.` : '',
329
+ res.remaining ? `${res.remaining} more stay in Ready: tick them and press again.` : '',
330
+ ].filter(Boolean);
331
+ return [`${sent}${notes.length ? ` ${notes.join(' ')}` : ''}`, res.held || res.waiting ? 'info' : 'success'];
332
+ }, (res) => (res.issued ? (to ? 'draft' : 'approved') : null));
297
333
  },
298
334
  });
299
335
  }
@@ -301,7 +337,11 @@ export async function renderInvoiceList(main, ui) {
301
337
  if (state.status === 'pending' && context.canIssue && to && failed.length) {
302
338
  status.push({
303
339
  icon: 'refresh-cw', label: 'Retry', primary: true, title: `Send again to ${to}`,
304
- run: () => post('send-again', { invoiceIds: failed }, (res) => [`${res.queued} on the way to ${to} again.`, 'success']),
340
+ run: (working) => {
341
+ working('Sending');
342
+ return post('send-again', { invoiceIds: failed }, (res) => [`${res.queued} on the way to ${to} again.`, 'success'],
343
+ (res) => (res.queued ? 'draft' : null));
344
+ },
305
345
  });
306
346
  }
307
347
  apply.push({
@@ -318,6 +358,21 @@ export async function renderInvoiceList(main, ui) {
318
358
  run: () => voidAll(voidable),
319
359
  });
320
360
  }
361
+ // A draft that was never issued is deleted on Pending and Ready. An issued
362
+ // invoice is deleted only while Flow's copy of Xero says Draft. Approved
363
+ // or paid stays hidden. A mix runs every ticked row and reports each one.
364
+ const fresh = rows.filter((i) => i.status === 'draft' && i.issuedVersion === 0);
365
+ const issuedXero = rows.filter((i) => i.status === 'issued' && xeroById.has(i.id));
366
+ const xeroDraft = issuedXero.filter((i) => xeroById.get(i.id)?.state === 'draft');
367
+ const xeroApproved = issuedXero.filter((i) => ['approved', 'part_paid', 'paid'].includes(xeroById.get(i.id)?.state));
368
+ const deletable = [...fresh, ...xeroDraft, ...xeroApproved];
369
+ const onDraftTabs = ['pending', 'ready'].includes(state.status) && fresh.length;
370
+ if (context.canIssue && (onDraftTabs || xeroDraft.length)) {
371
+ danger.push({
372
+ icon: 'trash-2', label: 'Delete', iconOnly: true, tone: 'danger', title: 'Delete',
373
+ run: () => deleteAll(deletable, { xero: xeroDraft.length + xeroApproved.length > 0 }),
374
+ });
375
+ }
321
376
  const all = body.invoices.map((i) => i.id);
322
377
  const selection = [
323
378
  { icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
@@ -393,6 +448,52 @@ export async function renderInvoiceList(main, ui) {
393
448
  alerts.refresh();
394
449
  }
395
450
 
451
+ // Delete, one invoice after another, as on one invoice's page. A Xero
452
+ // draft reports each row: deleted, or refused with the reason.
453
+ async function deleteAll(rows, { xero = false } = {}) {
454
+ const fresh = rows.some((i) => i.status === 'draft' && i.issuedVersion === 0);
455
+ const ok = await ui.dialog({
456
+ title: `Delete ${plural(rows.length, 'invoice', 'invoices')}?`,
457
+ body: xero
458
+ ? h('div', {},
459
+ fresh ? h('p', {}, 'A draft that was never issued is removed. Its job can be invoiced again, and the rules decide.') : null,
460
+ h('p', {}, 'Also deletes the draft in Xero.'))
461
+ : h('p', {}, 'A draft that was never issued is removed. Its job can be invoiced again, and the rules decide.'),
462
+ confirm: `Delete ${rows.length}`, tone: 'danger',
463
+ });
464
+ if (!ok) return;
465
+ if (xero) {
466
+ const results = [];
467
+ for (const invoice of rows) {
468
+ const name = invoice.number ?? 'Draft';
469
+ try {
470
+ await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
471
+ results.push(`${name} deleted.`);
472
+ } catch (error) {
473
+ results.push(`${name} refused: ${error.message}`);
474
+ }
475
+ }
476
+ const refused = results.some((line) => line.includes(' refused: '));
477
+ // The result stays until it is closed. A success is one short line, and
478
+ // a refusal names each invoice, so neither should vanish on its own.
479
+ toast(results.join(' '), refused ? 'danger' : 'success', { stay: true });
480
+ } else {
481
+ let done = 0;
482
+ let error = null;
483
+ for (const invoice of rows) {
484
+ try {
485
+ await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
486
+ done += 1;
487
+ } catch (e) { error = e; }
488
+ }
489
+ toast(error ? `${done} of ${rows.length} deleted. ${error.message}` : `${plural(done, 'invoice', 'invoices')} deleted.`, error ? 'danger' : 'success', { stay: true });
490
+ }
491
+ state.ticked.clear();
492
+ seen.clear();
493
+ await load();
494
+ alerts.refresh();
495
+ }
496
+
396
497
  // A tab switch shows at once: the tab is selected, the last list seen for
397
498
  // it (if any) is drawn and dimmed, and the fresh list replaces it. Only the
398
499
  // newest request draws, so quick clicks never show an older tab's list.
@@ -402,7 +503,7 @@ export async function renderInvoiceList(main, ui) {
402
503
  const query = new URLSearchParams({
403
504
  status: state.status, offset: String(state.offset), limit: String(state.limit), today: today(),
404
505
  ...(state.q ? { q: state.q } : {}),
405
- ...(filterCount(state.view, filterKind) ? { filter: JSON.stringify(state.view) } : {}),
506
+ ...(filterCount(state.view, kindOf) ? { filter: JSON.stringify(state.view) } : {}),
406
507
  });
407
508
  const key = String(query);
408
509
  const mine = ++asked;
@@ -429,6 +530,19 @@ export async function renderInvoiceList(main, ui) {
429
530
  host.removeAttribute('aria-busy');
430
531
  delete host.dataset.stale;
431
532
  draw(answer);
533
+ watchSending(answer);
534
+ }
535
+
536
+ // While a shown invoice is still Sending, the list reads again every 15
537
+ // seconds, so a person sees it reach the accounting system.
538
+ let sendingTimer = null;
539
+ function watchSending(answer) {
540
+ clearTimeout(sendingTimer);
541
+ if (!answer.invoices?.some((i) => i.sending)) return;
542
+ const key = state.status;
543
+ sendingTimer = setTimeout(() => {
544
+ if (host.isConnected && state.status === key && !state.ticked.size) { seen.clear(); load(); }
545
+ }, 15_000);
432
546
  }
433
547
 
434
548
  function drawTabs(counts) {
@@ -445,6 +559,7 @@ export async function renderInvoiceList(main, ui) {
445
559
  raw = answer;
446
560
  filterMenu.draw();
447
561
  body = answer;
562
+ xeroById = new Map();
448
563
  // A page past the end (rows went to another tab): show the last one.
449
564
  if (!answer.invoices.length && answer.total && answer.offset) {
450
565
  state.offset = Math.max(0, Math.floor((answer.total - 1) / state.limit) * state.limit);
@@ -459,7 +574,7 @@ export async function renderInvoiceList(main, ui) {
459
574
  drawTabs(body.counts);
460
575
  drawBar();
461
576
  if (!body.invoices.length) {
462
- host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, filterKind)
577
+ host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, kindOf)
463
578
  ? 'No invoice in this tab passes the filter.'
464
579
  : state.q
465
580
  ? 'No invoice matches that search.'
@@ -521,7 +636,7 @@ export async function renderInvoiceList(main, ui) {
521
636
  const href = (invoice) => `/invoices/${invoice.id}`;
522
637
  const ALL = {
523
638
  number: numbered ? { kind: 'number', cell: (i) => link(href(i), i.number ?? 'Draft') } : null,
524
- client: { kind: 'client', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
639
+ client: { kind: 'client', phone: 'title', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
525
640
  reference: showProblem
526
641
  ? { label: 'Problem', kind: 'text', cell: problemCell }
527
642
  : { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
@@ -552,16 +667,22 @@ export async function renderInvoiceList(main, ui) {
552
667
  rowCells(h, columns, invoice),
553
668
  h('td', { class: 'fx-c-end' })))),
554
669
  }));
555
- // Issued and void invoices also show Xero's status, from Flow's copy:
556
- // in the Linked column every one gets an icon, so one not in Xero
557
- // stands out.
558
- if (numbered) {
670
+ // Issued invoices carry Xero's status on every tab, so Delete can follow
671
+ // Flow's copy. A draft that is out of sync sits on Pending, which has no
672
+ // number column, and is still a draft there. Icons stay on the numbered
673
+ // tabs, where one invoice not in Xero stands out.
674
+ const issuedIds = body.invoices.filter((i) => i.issuedVersion > 0).map((i) => i.id);
675
+ if (numbered || issuedIds.length) {
559
676
  const shownBody = body;
560
- const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, body.invoices.map((i) => i.id));
677
+ const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, numbered ? body.invoices.map((i) => i.id) : issuedIds);
561
678
  if (shownBody !== body) return;
562
- for (const [invoiceId, status] of statuses) {
563
- if (linked) { if (accountingSystem) statusCells.get(invoiceId)?.append(accountingIcon(ui, accountingSystem, status)); }
564
- else if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
679
+ xeroById = statuses;
680
+ if (state.ticked.size) drawBar();
681
+ if (numbered) {
682
+ for (const [invoiceId, status] of statuses) {
683
+ if (linked) { if (accountingSystem) statusCells.get(invoiceId)?.append(accountingIcon(ui, accountingSystem, status)); }
684
+ else if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
685
+ }
565
686
  }
566
687
  }
567
688
  }
@@ -596,26 +717,37 @@ function saveNav(nav) {
596
717
  // The bar above one invoice: Back to the list as it was, and the previous and
597
718
  // next invoice of that list. An invoice opened from elsewhere (the job board,
598
719
  // search, a link) steps through its own tab, read once here.
599
- function navBar(ui, invoice) {
720
+ function navBar(ui, invoice, title = 'Invoice') {
600
721
  const { h, go, flow } = ui;
601
722
  const id = invoice?.id ?? null;
602
723
  const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
603
- const step = (to, label, path) => {
604
- const button = h('button', {
605
- class: 'fx-pager__step', type: 'button', disabled: !to, 'aria-label': label, title: label,
606
- onclick: () => go(`/invoices/${to}`),
607
- });
608
- button.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
609
- return button;
610
- };
611
- const bar = h('nav', { class: 'fx-pager', 'aria-label': 'Invoices' },
612
- h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back'));
724
+ const ends = { prev: null, next: null };
725
+ const goTo = (key) => { if (ends[key]) go(`/invoices/${ends[key]}`); };
726
+ const record = recordBar(h, {
727
+ title,
728
+ label: 'Invoices',
729
+ onBack: back,
730
+ prev: { label: 'Previous invoice', disabled: true, onclick: () => goTo('prev') },
731
+ next: { label: 'Next invoice', disabled: true, onclick: () => goTo('next') },
732
+ });
733
+ const { element: bar, draw } = pager(h, { label: 'Invoices', back });
613
734
  const fill = (ids) => {
614
735
  const at = ids.indexOf(id);
615
736
  if (at < 0) return false;
616
- bar.replaceChildren(bar.firstChild,
617
- h('span', { class: 'fx-pager__count' }, `${at + 1} of ${ids.length}`),
618
- 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')));
737
+ ends.prev = ids[at - 1] ?? null;
738
+ ends.next = ids[at + 1] ?? null;
739
+ record.setSteps(
740
+ { label: 'Previous invoice', disabled: !ends.prev, onclick: () => goTo('prev') },
741
+ { label: 'Next invoice', disabled: !ends.next, onclick: () => goTo('next') },
742
+ );
743
+ const to = (other) => (other ? () => go(`/invoices/${other}`) : null);
744
+ draw({
745
+ count: `${at + 1} of ${ids.length}`,
746
+ prev: to(ends.prev),
747
+ next: to(ends.next),
748
+ prevLabel: 'Previous invoice',
749
+ nextLabel: 'Next invoice',
750
+ });
619
751
  return true;
620
752
  };
621
753
  const tab = invoice?.tab ?? null;
@@ -627,7 +759,60 @@ function navBar(ui, invoice) {
627
759
  try { sessionStorage.setItem('flow_invoice_tab', tab); } catch { /* Back opens the last tab */ }
628
760
  }).catch(() => { /* Back only */ });
629
761
  }
630
- return bar;
762
+ return { pager: bar, record };
763
+ }
764
+
765
+ // A menu entry that presses a header button. Hidden and foot actions are skipped.
766
+ function menuEntry(button, icon, skip) {
767
+ if (!button || button.hidden || skip.has(button)) return null;
768
+ return {
769
+ icon,
770
+ text: button.textContent.trim(),
771
+ tone: button.classList.contains('fx-btn--danger') ? 'danger' : undefined,
772
+ run: () => button.click(),
773
+ };
774
+ }
775
+
776
+ // The phone record: the shared bar, the primary action in the foot, and the
777
+ // other actions in ⋯. Above a phone the header and the pager stay as they
778
+ // are; the bar and the foot are hidden by CSS. The page's h1 moves into the
779
+ // bar so the phone suite still finds the invoice title.
780
+ function mountRecord(ui, invoice, header, primaryOf, menuOf) {
781
+ const { h } = ui;
782
+ const titleNode = header.querySelector('h1');
783
+ const nav = navBar(ui, invoice, titleNode.textContent);
784
+ const slot = nav.record.querySelector('.fx-record__title');
785
+ const foot = h('div', { class: 'fx-record__foot', hidden: true });
786
+ const items = [];
787
+ const [menuButton, menuPanel] = rowMenu('More', items, { className: 'fx-record__menu' });
788
+ nav.record.setMenu(menuButton);
789
+ const home = header.querySelector('.fx-ph__actions');
790
+ // The page header is the bar's title, so the phone suite still sees it.
791
+ // Above a phone it stays above the invoice, and the bar is hidden.
792
+ const placeHeader = () => {
793
+ const end = nav.record.querySelector('.fx-record__end');
794
+ if (!isPhone()) {
795
+ slot.hidden = false;
796
+ titleNode.removeAttribute('title');
797
+ if (header.parentElement === nav.record) nav.pager.after(header);
798
+ return;
799
+ }
800
+ slot.hidden = true;
801
+ titleNode.title = titleNode.textContent;
802
+ if (header.parentElement !== nav.record) nav.record.insertBefore(header, end);
803
+ };
804
+ const placeMenu = () => {
805
+ const skip = new Set([primaryOf()].filter(Boolean));
806
+ items.length = 0;
807
+ if (isPhone()) for (const entry of menuOf(skip)) if (entry) items.push(entry);
808
+ menuButton.hidden = items.length === 0;
809
+ };
810
+ const placePrimary = home ? watchPhonePrimary(home, foot, primaryOf) : () => {};
811
+ const sync = () => { placeHeader(); placeMenu(); placePrimary(); };
812
+ sync();
813
+ queueMicrotask(sync);
814
+ if (typeof matchMedia === 'function') matchMedia(WIDTHS.phone).addEventListener('change', sync);
815
+ return { pager: nav.pager, record: nav.record, foot, menuPanel, sync };
631
816
  }
632
817
 
633
818
  // After a send, an Approve or a Save that makes a Pending invoice Ready, the
@@ -933,7 +1118,11 @@ function draftView(invoice, ui, context, redraw) {
933
1118
  }, 'Save');
934
1119
  const opened = JSON.stringify(body());
935
1120
  const changed = () => !isNew && JSON.stringify(body()) !== opened;
936
- const watchChanges = () => { if (!keepsSave) saveButton.hidden = !changed(); };
1121
+ let syncRecord = () => {};
1122
+ const watchChanges = () => {
1123
+ if (!keepsSave) saveButton.hidden = !changed();
1124
+ syncRecord();
1125
+ };
937
1126
  // With an accounting system on, a ready invoice is sent there: Flow issues
938
1127
  // it and queues it, the same as Send all ready. With none, Flow issues it
939
1128
  // and keeps it. A Pending draft has no send: fix its problem first.
@@ -957,7 +1146,7 @@ function draftView(invoice, ui, context, redraw) {
957
1146
  toast(`${fresh.number} is on the way to ${to}.`, 'success');
958
1147
  openNext(ui, invoice.id);
959
1148
  } catch (error) {
960
- toast(error.message, 'danger');
1149
+ toast(error.message, 'danger', { stay: true });
961
1150
  }
962
1151
  },
963
1152
  }, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
@@ -1004,6 +1193,11 @@ function draftView(invoice, ui, context, redraw) {
1004
1193
  // fine too.
1005
1194
  const fromJob = isNew && context.canIssue
1006
1195
  ? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => pickJob(ui) }, 'From a job') : null;
1196
+ // A draft that was never issued is deleted. An issued invoice, including
1197
+ // one opened for a revision, is voided instead, unless its Xero copy is a
1198
+ // draft: that Delete is added on the issued page after the copy loads.
1199
+ const deleteButton = !isNew && context.canIssue && invoice.status === 'draft' && invoice.issuedVersion === 0
1200
+ ? deleteControl(ui, invoice, { xeroDraft: false }) : null;
1007
1201
 
1008
1202
  // A draft with no problem is in Ready by itself; one with a problem is
1009
1203
  // Pending. A changed job is fixed at its source or accepted here.
@@ -1011,13 +1205,44 @@ function draftView(invoice, ui, context, redraw) {
1011
1205
  // The job changed after the invoice was made: its own banner shows what
1012
1206
  // changed. Other problems stay one line.
1013
1207
  const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
1208
+ const offer = otherProblems.find((p) => p.code === 'xero_contact_missing' && p.offerCreate);
1209
+ const problemNotice = otherProblems.length
1210
+ ? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.')
1211
+ : null;
1212
+ const organisationId = context.organisations[0]?.id;
1213
+ const createClient = offer && context.canIssue && invoice?.contact?.id && organisationId
1214
+ ? h('button', {
1215
+ class: 'fx-btn fx-btn--primary', type: 'button', title: 'Create this client in Xero',
1216
+ onclick: async (event) => {
1217
+ const button = event.currentTarget;
1218
+ const name = invoice.contact.name;
1219
+ const confirmed = await ui.dialog({
1220
+ title: `Create ${name} in Xero?`,
1221
+ body: h('p', {}, 'Flow adds this client to Xero from the contact.'),
1222
+ confirm: 'Create',
1223
+ });
1224
+ if (!confirmed) return;
1225
+ button.disabled = true;
1226
+ try {
1227
+ await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: invoice.contact.id }) });
1228
+ toast(`${name} is in Xero.`, 'success');
1229
+ const body = await flow(`/api/invoicing/invoices/${invoice.id}`);
1230
+ redraw(body.invoice);
1231
+ } catch (error) {
1232
+ toast(error.message, error.status === 429 ? 'info' : 'danger');
1233
+ if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
1234
+ else button.disabled = false;
1235
+ }
1236
+ },
1237
+ }, 'Create')
1238
+ : null;
1014
1239
  const problems = [
1015
1240
  // Accepting changes the draft for the business: owners and admins only.
1016
1241
  jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
1017
- otherProblems.length ? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.') : null,
1242
+ problemNotice ? (createClient ? h('div', { class: 'fx-offer' }, problemNotice, createClient) : problemNotice) : null,
1018
1243
  ];
1019
1244
  // A red field names the banner as its reason (for a screen reader).
1020
- const banner = problems[1];
1245
+ const banner = problemNotice;
1021
1246
  if (banner) banner.id = 'fx-invoice-problem';
1022
1247
  for (const name of failed) {
1023
1248
  if (!boxes[name]) continue;
@@ -1025,11 +1250,25 @@ function draftView(invoice, ui, context, redraw) {
1025
1250
  if (banner) boxes[name].setAttribute('aria-describedby', banner.id);
1026
1251
  }
1027
1252
 
1253
+ const previewBtn = isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.');
1254
+ const showAccept = Boolean(jobChange?.canAccept && context.canIssue);
1255
+ const header = ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
1256
+ actions: [fromJob, deleteButton, previewBtn, saveButton, sendButton ?? issueButton].filter(Boolean),
1257
+ });
1258
+ const primaryOf = () => (showAccept ? null : (sendButton ?? issueButton ?? (saveButton.hidden ? null : saveButton)));
1259
+ const chrome = mountRecord(ui, invoice, header, primaryOf, (skip) => [
1260
+ menuEntry(fromJob, 'briefcase', skip),
1261
+ menuEntry(deleteButton, 'trash-2', skip),
1262
+ menuEntry(previewBtn, 'eye', skip),
1263
+ menuEntry(saveButton, 'check', skip),
1264
+ menuEntry(sendButton, 'mail', skip),
1265
+ menuEntry(issueButton, 'check', skip),
1266
+ ]);
1267
+ syncRecord = chrome.sync;
1028
1268
  return [
1029
- navBar(ui, invoice),
1030
- ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
1031
- actions: [fromJob, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton].filter(Boolean),
1032
- }),
1269
+ chrome.record,
1270
+ chrome.pager,
1271
+ header,
1033
1272
  draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
1034
1273
  ...problems,
1035
1274
  h('div', { class: 'fx-invoice__grid' },
@@ -1050,6 +1289,8 @@ function draftView(invoice, ui, context, redraw) {
1050
1289
  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'),
1051
1290
  totalsHost))),
1052
1291
  h('aside', { class: 'fx-invoice__aside' }, linkedCard(ui, invoice))),
1292
+ chrome.foot,
1293
+ chrome.menuPanel,
1053
1294
  ].map((part) => {
1054
1295
  // Any edit on the page shows Save changes; undoing it hides it again.
1055
1296
  for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
@@ -1090,6 +1331,35 @@ function previewButton(ui, invoiceId, title = 'Shows the invoice as a PDF.') {
1090
1331
  }, 'Preview');
1091
1332
  }
1092
1333
 
1334
+ // Delete asks first. A Xero draft says so; a never-issued draft says the job
1335
+ // can be invoiced again. Then the list opens.
1336
+ function deleteControl(ui, invoice, { xeroDraft }) {
1337
+ const { h, flow, toast, go } = ui;
1338
+ return h('button', {
1339
+ class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button', 'data-delete': '',
1340
+ onclick: async () => {
1341
+ const ok = await ui.dialog({
1342
+ title: 'Delete this invoice?',
1343
+ body: h('p', {}, xeroDraft
1344
+ ? 'Also deletes the draft in Xero.'
1345
+ : invoice.job
1346
+ ? 'It was never issued. The job can be invoiced again, and the rules decide.'
1347
+ : 'It was never issued, so it is removed.'),
1348
+ confirm: 'Delete', tone: 'danger',
1349
+ });
1350
+ if (!ok) return;
1351
+ try {
1352
+ await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
1353
+ toast('Invoice deleted.', 'success', { stay: true });
1354
+ sessionStorage.setItem(NAV_BACK, '1');
1355
+ go('/invoices');
1356
+ } catch (error) {
1357
+ toast(error.message, 'danger', { stay: true });
1358
+ }
1359
+ },
1360
+ }, 'Delete');
1361
+ }
1362
+
1093
1363
  // An issued or void invoice: the document as sent, and what can happen next.
1094
1364
  function issuedView(invoice, ui, context, redraw) {
1095
1365
  const { h, flow, toast } = ui;
@@ -1111,14 +1381,28 @@ function issuedView(invoice, ui, context, redraw) {
1111
1381
  const linkedHost = h('div', {}, linkedCard(ui, invoice) ?? '');
1112
1382
  let xeroStatus = null;
1113
1383
  const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
1384
+ let actionBar = null;
1385
+ let deleteButton = null;
1386
+ let recordSync = () => {};
1114
1387
  const loadXero = async () => {
1115
1388
  if (!context.xero) return;
1116
1389
  xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
1117
1390
  xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
1118
1391
  linkedHost.replaceChildren(linkedCard(ui, invoice, xeroStatus) ?? '');
1119
1392
  if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
1393
+ // Delete only while Flow's copy says Draft. Approved and paid stay on Void.
1394
+ const canDelete = invoice.status === 'issued' && context.canIssue && xeroStatus?.state === 'draft';
1395
+ const remove = actionBar?.querySelector('[data-delete]');
1396
+ if (canDelete && !remove && actionBar) {
1397
+ deleteButton = deleteControl(ui, invoice, { xeroDraft: true });
1398
+ actionBar.prepend(deleteButton);
1399
+ }
1400
+ if (!canDelete && remove) {
1401
+ remove.remove();
1402
+ deleteButton = null;
1403
+ }
1404
+ recordSync();
1120
1405
  };
1121
- loadXero();
1122
1406
  // Voiding in Flow never changes Xero: say so while Xero still holds it live.
1123
1407
  const voidBody = () => {
1124
1408
  const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
@@ -1126,20 +1410,40 @@ function issuedView(invoice, ui, context, redraw) {
1126
1410
  h('p', {}, 'The number stays used. A void invoice cannot be used again.'),
1127
1411
  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);
1128
1412
  };
1129
- const actions = invoice.status === 'issued' && context.canIssue ? [
1130
- h('button', {
1131
- class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
1132
- onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
1133
- }, 'Void'),
1134
- h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
1135
- ] : [];
1413
+ const voidButton = invoice.status === 'issued' && context.canIssue ? h('button', {
1414
+ class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
1415
+ onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
1416
+ }, 'Void') : null;
1417
+ const reviseButton = invoice.status === 'issued' && context.canIssue
1418
+ ? h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise') : null;
1419
+ const preview = previewButton(ui, invoice.id);
1420
+ actionBar = h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean));
1421
+ const header = h('header', { class: 'fx-ph' },
1422
+ h('div', { class: 'fx-ph__text' },
1423
+ h('h1', { class: 'fx-title' }, invoice.number),
1424
+ h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
1425
+ actionBar);
1426
+ const chrome = mountRecord(ui, invoice, header, () => send, (skip) => [
1427
+ menuEntry(deleteButton, 'trash-2', skip),
1428
+ menuEntry(voidButton, 'ban', skip),
1429
+ menuEntry(reviseButton, 'pencil', skip),
1430
+ menuEntry(preview, 'eye', skip),
1431
+ ]);
1432
+ recordSync = chrome.sync;
1433
+ loadXero();
1434
+ const history = [
1435
+ ...(invoice.versions ?? []).map((v) => h('li', {},
1436
+ h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
1437
+ h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))),
1438
+ // Void, including a delete that removed the Xero draft, sits on the same list.
1439
+ ...(invoice.status === 'void' ? [h('li', {},
1440
+ h('span', { class: 'fx-strong' }, 'Void'),
1441
+ h('span', { class: 'fx-dim' }, new Date(invoice.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))] : []),
1442
+ ];
1136
1443
  return [
1137
- navBar(ui, invoice),
1138
- h('header', { class: 'fx-ph' },
1139
- h('div', { class: 'fx-ph__text' },
1140
- h('h1', { class: 'fx-title' }, invoice.number),
1141
- h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
1142
- h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
1444
+ chrome.record,
1445
+ chrome.pager,
1446
+ header,
1143
1447
  h('div', { class: 'fx-invoice__grid' },
1144
1448
  h('div', { class: 'fx-invoice__main' },
1145
1449
  h('section', { class: 'fx-card fx-doc' },
@@ -1160,11 +1464,11 @@ function issuedView(invoice, ui, context, redraw) {
1160
1464
  h('aside', { class: 'fx-invoice__aside' },
1161
1465
  linkedHost,
1162
1466
  xeroHost,
1163
- invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
1467
+ history.length ? h('section', { class: 'fx-card fx-side-card' },
1164
1468
  h('h3', { class: 'fx-label' }, 'History'),
1165
- h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
1166
- h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
1167
- h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
1469
+ h('ol', { class: 'fx-timeline' }, history)) : null)),
1470
+ chrome.foot,
1471
+ chrome.menuPanel,
1168
1472
  ];
1169
1473
  }
1170
1474