@arcforgelabs/flow 2026.10.45 → 2026.10.47

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 (49) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +104 -2
  4. package/package.json +6 -2
  5. package/server/api/invoicing.js +56 -16
  6. package/server/app.js +4 -2
  7. package/server/auth/people.js +5 -3
  8. package/server/auth/routes.js +4 -1
  9. package/server/core/invoice-rules.js +34 -4
  10. package/server/core/invoices.js +40 -1
  11. package/server/core/invoicing-rules.js +20 -3
  12. package/server/core/job-board.js +1 -1
  13. package/server/core/sending.js +76 -22
  14. package/server/integrations/call-guard.js +21 -4
  15. package/server/integrations/sentry/index.js +14 -7
  16. package/server/integrations/xero/client.js +12 -2
  17. package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
  18. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  19. package/server/integrations/xero/routes.js +29 -5
  20. package/server/integrations/xero/runtime.js +5 -0
  21. package/server/integrations/xero/usage.js +74 -0
  22. package/server/ui/alerts.js +2 -1
  23. package/server/ui/app.js +110 -27
  24. package/server/ui/bulk-bar.js +18 -5
  25. package/server/ui/columns.js +54 -2
  26. package/server/ui/custom-fields.js +22 -7
  27. package/server/ui/flow.css +353 -45
  28. package/server/ui/invoicing.js +286 -64
  29. package/server/ui/job-board.js +10 -0
  30. package/server/ui/job-change.js +4 -3
  31. package/server/ui/job-view.js +14 -4
  32. package/server/ui/jobs.js +156 -56
  33. package/server/ui/kit.js +212 -51
  34. package/server/ui/list-views.js +50 -10
  35. package/server/ui/mobile.js +726 -0
  36. package/server/ui/monitoring.js +51 -0
  37. package/server/ui/nav-pins.js +41 -0
  38. package/server/ui/page.js +110 -5
  39. package/server/ui/pager.js +57 -10
  40. package/server/ui/price-list.js +1 -1
  41. package/server/ui/price-reconcile.js +12 -12
  42. package/server/ui/reconcile.js +19 -18
  43. package/server/ui/routes.js +1 -1
  44. package/server/ui/saved-views.js +6 -5
  45. package/server/ui/sidecar.js +106 -6
  46. package/server/ui/view-bar.js +22 -11
  47. package/server/ui/xero-import.js +48 -19
  48. package/server/ui/xero-usage-model.js +106 -0
  49. package/server/ui/xero-usage.js +133 -0
@@ -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 { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
14
- import { filterCount, filterJobs, INVOICE_COLUMNS, normaliseListView } from '/assets/ui/job-view.js';
15
- import { INVOICE_FILTER_FIELDS, invoiceFilterKind, invoiceValue, normaliseInvoicesView } from '/assets/ui/list-views.js';
16
- import { listPager, PAGE_SIZES } from '/assets/ui/pager.js';
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';
16
+ import { columnTable, dateCell, fieldColumn, rowCells, viewSaver } from '/assets/ui/columns.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() {
@@ -149,21 +153,25 @@ export async function renderInvoiceList(main, ui) {
149
153
  status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set(), view: readFilters(),
150
154
  // The page of rows shown: the server filters and cuts it (GET /invoices).
151
155
  offset: returning ? back?.offset ?? 0 : 0, limit: readSize(),
152
- columns: normaliseListView(ui.me?.views?.invoices, INVOICE_COLUMNS),
156
+ columns: invoiceColumnsView(ui.me?.views?.invoices),
157
+ // The lab's custom fields, their job values and the people a person
158
+ // field names (GET /api/custom-fields): columns a person can add.
159
+ fields: [], values: new Map(), people: [],
153
160
  };
154
161
  // The list's columns: resize, move, hide, kept with the account. A tab
155
- // has some of them (`available`, set as it draws).
162
+ // has some of them (`available`, set as it draws), each with its label.
156
163
  let available = Object.keys(INVOICE_COLUMNS);
164
+ const labelOf = (id) => INVOICE_COLUMNS[id] ?? state.fields.find((f) => fieldGroup(f.id) === id)?.name ?? id;
157
165
  const saveColumns = viewSaver(ui, 'invoices');
158
166
  const heads = columnTable(h, {
159
167
  order: () => state.columns.columns,
160
168
  widths: () => state.columns.widths,
161
169
  change: (patch) => {
162
- state.columns = normaliseListView({ ...state.columns, ...patch }, INVOICE_COLUMNS);
170
+ state.columns = invoiceColumnsView({ ...state.columns, ...patch });
163
171
  saveColumns(state.columns);
164
172
  if (raw) draw(raw);
165
173
  },
166
- hidden: () => available.filter((id) => !state.columns.columns.includes(id)).map((id) => ({ id, label: INVOICE_COLUMNS[id] })),
174
+ hidden: () => available.filter((id) => !state.columns.columns.includes(id)).map((id) => ({ id, label: labelOf(id) })),
167
175
  });
168
176
  const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
169
177
  let timer;
@@ -190,11 +198,27 @@ export async function renderInvoiceList(main, ui) {
190
198
  const opening = views.opening();
191
199
  if (opening) state.view = normaliseInvoicesView(opening);
192
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 }));
193
206
  const filterMenu = viewBar(h).filterMenu({
194
- fields: () => Object.entries(FILTER_FIELDS).map(([value, f]) => ({ value, label: f.label, icon: f.icon })),
195
- kindOf: filterKind,
196
- opsOf: (field) => (field === 'problem' ? PROBLEM_OPS : null),
197
- choicesOf: (field) => (field === 'client' ? (raw?.clients ?? []).map((v) => ({ value: v, label: v })) : []),
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
+ },
198
222
  view: () => state.view,
199
223
  change: setView,
200
224
  first: 'client',
@@ -215,7 +239,15 @@ export async function renderInvoiceList(main, ui) {
215
239
  load();
216
240
  },
217
241
  });
218
- const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
242
+ // Custom fields are extra: the list still shows without them.
243
+ const [context, fields, values] = await Promise.all([
244
+ flow('/api/invoicing/context').catch(() => ({ canIssue: false })),
245
+ flow('/api/custom-fields').catch(() => null), flow('/api/custom-fields/values').catch(() => null),
246
+ ]);
247
+ state.fields = fields?.fields ?? [];
248
+ state.people = fields?.people ?? [];
249
+ const valueKey = (job, fieldId) => `${job.sourceSystem}\u0000${job.sourceJobRef}\u0000${fieldId}`;
250
+ state.values = new Map((values?.values ?? []).map((v) => [valueKey(v, v.fieldId), v.value]));
219
251
  let body = null;
220
252
  let raw = null;
221
253
  const openTab = (id) => { state.status = id; state.offset = 0; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
@@ -225,7 +257,7 @@ export async function renderInvoiceList(main, ui) {
225
257
  // action: tick rows (the head tick takes every row shown), then Send.
226
258
  const head = listHead('Invoices', {
227
259
  actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
228
- views: views.element, start: tabs, end: [controls, find.element], line: true, sticky: true,
260
+ views: views.element, start: tabs, controls, controlsAt: 'end', end: find.element, line: true, sticky: true,
229
261
  });
230
262
  const [title, ...top] = head.nodes;
231
263
  const frame = listFrame(main, host);
@@ -247,17 +279,20 @@ export async function renderInvoiceList(main, ui) {
247
279
  }
248
280
 
249
281
  // A bulk step on the server: on success the ticks clear and the list loads
250
- // again; on a failure the ticks stay, so a person can try again.
251
- async function post(path, payload, done) {
282
+ // again (or `then(res)` names the tab the invoices went to, and it opens);
283
+ // on a failure the ticks stay, so a person can try again.
284
+ async function post(path, payload, done, then = () => null) {
252
285
  try {
253
286
  const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload) });
254
287
  toast(...done(res));
255
288
  state.ticked.clear();
256
289
  seen.clear();
257
- await load();
290
+ const tab = then(res);
291
+ if (tab && tab !== state.status) openTab(tab);
292
+ else await load();
258
293
  alerts.refresh();
259
294
  } catch (error) {
260
- toast(error.message, 'danger');
295
+ toast(error.message, 'danger', { stay: true });
261
296
  }
262
297
  }
263
298
 
@@ -273,15 +308,25 @@ export async function renderInvoiceList(main, ui) {
273
308
  status.push({
274
309
  icon: to ? 'send' : 'circle-check-big', label: to ? `Send to ${to}` : 'Approve', primary: true,
275
310
  title: to ? `Send to ${to}` : 'Approve',
276
- run: async () => {
311
+ run: async (working) => {
277
312
  const ok = await ui.dialog({
278
313
  title: to ? `Send ${plural(ticked.length, 'invoice', 'invoices')} to ${to}?` : `Approve ${plural(ticked.length, 'invoice', 'invoices')}?`,
279
314
  body: h('p', {}, to ? sendModeNote(to, body.sendMode, 'each one') : 'Each one gets the next number and is locked.'),
280
315
  confirm: to ? `Send ${ticked.length}` : `Approve ${ticked.length}`,
281
316
  });
282
317
  if (!ok) return;
283
- await post('send-ready', { issueDate: localDate(), invoiceIds: ticked },
284
- (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']);
318
+ working(to ? 'Sending' : 'Approving');
319
+ // The sent ones show on Draft as Sending until the accounting
320
+ // system has them; the approved ones on Approved.
321
+ await post('send-ready', { issueDate: localDate(), invoiceIds: ticked }, (res) => {
322
+ const sent = to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`;
323
+ const notes = [
324
+ res.held ? `${res.held} failed a check and went to Pending.` : '',
325
+ res.waiting ? `${res.waiting} could not be confirmed with the work system yet: press Send again.` : '',
326
+ res.remaining ? `${res.remaining} more stay in Ready: tick them and press again.` : '',
327
+ ].filter(Boolean);
328
+ return [`${sent}${notes.length ? ` ${notes.join(' ')}` : ''}`, res.held || res.waiting ? 'info' : 'success'];
329
+ }, (res) => (res.issued ? (to ? 'draft' : 'approved') : null));
285
330
  },
286
331
  });
287
332
  }
@@ -289,7 +334,11 @@ export async function renderInvoiceList(main, ui) {
289
334
  if (state.status === 'pending' && context.canIssue && to && failed.length) {
290
335
  status.push({
291
336
  icon: 'refresh-cw', label: 'Retry', primary: true, title: `Send again to ${to}`,
292
- run: () => post('send-again', { invoiceIds: failed }, (res) => [`${res.queued} on the way to ${to} again.`, 'success']),
337
+ run: (working) => {
338
+ working('Sending');
339
+ return post('send-again', { invoiceIds: failed }, (res) => [`${res.queued} on the way to ${to} again.`, 'success'],
340
+ (res) => (res.queued ? 'draft' : null));
341
+ },
293
342
  });
294
343
  }
295
344
  apply.push({
@@ -306,6 +355,15 @@ export async function renderInvoiceList(main, ui) {
306
355
  run: () => voidAll(voidable),
307
356
  });
308
357
  }
358
+ // A draft that was never issued is deleted, not voided. Pending and Ready
359
+ // hold those drafts; an issued invoice on Pending is retried, not deleted.
360
+ const deletable = rows.filter((i) => i.status === 'draft' && i.issuedVersion === 0);
361
+ if (['pending', 'ready'].includes(state.status) && context.canIssue && deletable.length) {
362
+ danger.push({
363
+ icon: 'trash-2', label: 'Delete', iconOnly: true, tone: 'danger', title: 'Delete',
364
+ run: () => deleteAll(deletable),
365
+ });
366
+ }
309
367
  const all = body.invoices.map((i) => i.id);
310
368
  const selection = [
311
369
  { icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
@@ -381,6 +439,29 @@ export async function renderInvoiceList(main, ui) {
381
439
  alerts.refresh();
382
440
  }
383
441
 
442
+ // Delete, one never-issued draft after another, as on one invoice's page.
443
+ async function deleteAll(rows) {
444
+ const ok = await ui.dialog({
445
+ title: `Delete ${plural(rows.length, 'invoice', 'invoices')}?`,
446
+ body: h('p', {}, 'A draft that was never issued is removed. Its job can be invoiced again, and the rules decide.'),
447
+ confirm: `Delete ${rows.length}`, tone: 'danger',
448
+ });
449
+ if (!ok) return;
450
+ let done = 0;
451
+ let error = null;
452
+ for (const invoice of rows) {
453
+ try {
454
+ await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
455
+ done += 1;
456
+ } catch (e) { error = e; }
457
+ }
458
+ toast(error ? `${done} of ${rows.length} deleted. ${error.message}` : `${plural(done, 'invoice', 'invoices')} deleted.`, error ? 'danger' : 'success');
459
+ state.ticked.clear();
460
+ seen.clear();
461
+ await load();
462
+ alerts.refresh();
463
+ }
464
+
384
465
  // A tab switch shows at once: the tab is selected, the last list seen for
385
466
  // it (if any) is drawn and dimmed, and the fresh list replaces it. Only the
386
467
  // newest request draws, so quick clicks never show an older tab's list.
@@ -390,7 +471,7 @@ export async function renderInvoiceList(main, ui) {
390
471
  const query = new URLSearchParams({
391
472
  status: state.status, offset: String(state.offset), limit: String(state.limit), today: today(),
392
473
  ...(state.q ? { q: state.q } : {}),
393
- ...(filterCount(state.view, filterKind) ? { filter: JSON.stringify(state.view) } : {}),
474
+ ...(filterCount(state.view, kindOf) ? { filter: JSON.stringify(state.view) } : {}),
394
475
  });
395
476
  const key = String(query);
396
477
  const mine = ++asked;
@@ -417,6 +498,19 @@ export async function renderInvoiceList(main, ui) {
417
498
  host.removeAttribute('aria-busy');
418
499
  delete host.dataset.stale;
419
500
  draw(answer);
501
+ watchSending(answer);
502
+ }
503
+
504
+ // While a shown invoice is still Sending, the list reads again every 15
505
+ // seconds, so a person sees it reach the accounting system.
506
+ let sendingTimer = null;
507
+ function watchSending(answer) {
508
+ clearTimeout(sendingTimer);
509
+ if (!answer.invoices?.some((i) => i.sending)) return;
510
+ const key = state.status;
511
+ sendingTimer = setTimeout(() => {
512
+ if (host.isConnected && state.status === key && !state.ticked.size) { seen.clear(); load(); }
513
+ }, 15_000);
420
514
  }
421
515
 
422
516
  function drawTabs(counts) {
@@ -447,7 +541,7 @@ export async function renderInvoiceList(main, ui) {
447
541
  drawTabs(body.counts);
448
542
  drawBar();
449
543
  if (!body.invoices.length) {
450
- host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, filterKind)
544
+ host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, kindOf)
451
545
  ? 'No invoice in this tab passes the filter.'
452
546
  : state.q
453
547
  ? 'No invoice matches that search.'
@@ -509,17 +603,28 @@ export async function renderInvoiceList(main, ui) {
509
603
  const href = (invoice) => `/invoices/${invoice.id}`;
510
604
  const ALL = {
511
605
  number: numbered ? { kind: 'number', cell: (i) => link(href(i), i.number ?? 'Draft') } : null,
512
- client: { kind: 'client', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
606
+ client: { kind: 'client', phone: 'title', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
513
607
  reference: showProblem
514
608
  ? { label: 'Problem', kind: 'text', cell: problemCell }
515
609
  : { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
516
- date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) => showDate(i.issueDate ?? i.createdAt?.slice(0, 10)) },
610
+ date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.issueDate ?? i.createdAt?.slice(0, 10)) },
611
+ due: { kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.dueDate) },
517
612
  total: { kind: 'total', num: true, className: 'fx-strong', cell: (i) => formatCents(i.totalCents) },
518
613
  status: { kind: 'status', className: 'fx-status-cell', cell: statusCell },
519
614
  linked: linked ? { kind: 'link', cell: linkCell } : null,
615
+ // The invoice's job (from the work system): its case, practitioner and
616
+ // the day it shipped.
617
+ case: { kind: 'case', className: 'fx-dim', cell: (i) => (i.job ? caseNumber(i.job) ?? '—' : '—') },
618
+ practitioner: { kind: 'text', className: 'fx-dim', cell: (i) => i.job?.practitioner ?? '—' },
619
+ shipped: { kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.job?.shippedOn ?? null) },
520
620
  };
621
+ // Each custom field is a column too: the value of the invoice's job.
622
+ for (const field of state.fields) {
623
+ const id = fieldGroup(field.id);
624
+ ALL[id] = fieldColumn(h, field, { id, valueOf: (i) => (i.job ? state.values.get(valueKey(i.job, field.id)) ?? null : null), people: state.people });
625
+ }
521
626
  available = Object.keys(ALL).filter((id) => ALL[id]);
522
- const columns = state.columns.columns.filter((id) => ALL[id]).map((id) => ({ id, label: INVOICE_COLUMNS[id], ...ALL[id] }));
627
+ const columns = state.columns.columns.filter((id) => ALL[id]).map((id) => ({ id, label: labelOf(id), ...ALL[id] }));
523
628
  host.replaceChildren(heads.table({
524
629
  className: 'fx-table--rows',
525
630
  columns,
@@ -573,26 +678,37 @@ function saveNav(nav) {
573
678
  // The bar above one invoice: Back to the list as it was, and the previous and
574
679
  // next invoice of that list. An invoice opened from elsewhere (the job board,
575
680
  // search, a link) steps through its own tab, read once here.
576
- function navBar(ui, invoice) {
681
+ function navBar(ui, invoice, title = 'Invoice') {
577
682
  const { h, go, flow } = ui;
578
683
  const id = invoice?.id ?? null;
579
684
  const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
580
- const step = (to, label, path) => {
581
- const button = h('button', {
582
- class: 'fx-pager__step', type: 'button', disabled: !to, 'aria-label': label, title: label,
583
- onclick: () => go(`/invoices/${to}`),
584
- });
585
- 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>`;
586
- return button;
587
- };
588
- const bar = h('nav', { class: 'fx-pager', 'aria-label': 'Invoices' },
589
- h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back'));
685
+ const ends = { prev: null, next: null };
686
+ const goTo = (key) => { if (ends[key]) go(`/invoices/${ends[key]}`); };
687
+ const record = recordBar(h, {
688
+ title,
689
+ label: 'Invoices',
690
+ onBack: back,
691
+ prev: { label: 'Previous invoice', disabled: true, onclick: () => goTo('prev') },
692
+ next: { label: 'Next invoice', disabled: true, onclick: () => goTo('next') },
693
+ });
694
+ const { element: bar, draw } = pager(h, { label: 'Invoices', back });
590
695
  const fill = (ids) => {
591
696
  const at = ids.indexOf(id);
592
697
  if (at < 0) return false;
593
- bar.replaceChildren(bar.firstChild,
594
- h('span', { class: 'fx-pager__count' }, `${at + 1} of ${ids.length}`),
595
- 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')));
698
+ ends.prev = ids[at - 1] ?? null;
699
+ ends.next = ids[at + 1] ?? null;
700
+ record.setSteps(
701
+ { label: 'Previous invoice', disabled: !ends.prev, onclick: () => goTo('prev') },
702
+ { label: 'Next invoice', disabled: !ends.next, onclick: () => goTo('next') },
703
+ );
704
+ const to = (other) => (other ? () => go(`/invoices/${other}`) : null);
705
+ draw({
706
+ count: `${at + 1} of ${ids.length}`,
707
+ prev: to(ends.prev),
708
+ next: to(ends.next),
709
+ prevLabel: 'Previous invoice',
710
+ nextLabel: 'Next invoice',
711
+ });
596
712
  return true;
597
713
  };
598
714
  const tab = invoice?.tab ?? null;
@@ -604,7 +720,60 @@ function navBar(ui, invoice) {
604
720
  try { sessionStorage.setItem('flow_invoice_tab', tab); } catch { /* Back opens the last tab */ }
605
721
  }).catch(() => { /* Back only */ });
606
722
  }
607
- return bar;
723
+ return { pager: bar, record };
724
+ }
725
+
726
+ // A menu entry that presses a header button. Hidden and foot actions are skipped.
727
+ function menuEntry(button, icon, skip) {
728
+ if (!button || button.hidden || skip.has(button)) return null;
729
+ return {
730
+ icon,
731
+ text: button.textContent.trim(),
732
+ tone: button.classList.contains('fx-btn--danger') ? 'danger' : undefined,
733
+ run: () => button.click(),
734
+ };
735
+ }
736
+
737
+ // The phone record: the shared bar, the primary action in the foot, and the
738
+ // other actions in ⋯. Above a phone the header and the pager stay as they
739
+ // are; the bar and the foot are hidden by CSS. The page's h1 moves into the
740
+ // bar so the phone suite still finds the invoice title.
741
+ function mountRecord(ui, invoice, header, primaryOf, menuOf) {
742
+ const { h } = ui;
743
+ const titleNode = header.querySelector('h1');
744
+ const nav = navBar(ui, invoice, titleNode.textContent);
745
+ const slot = nav.record.querySelector('.fx-record__title');
746
+ const foot = h('div', { class: 'fx-record__foot', hidden: true });
747
+ const items = [];
748
+ const [menuButton, menuPanel] = rowMenu('More', items, { className: 'fx-record__menu' });
749
+ nav.record.setMenu(menuButton);
750
+ const home = header.querySelector('.fx-ph__actions');
751
+ // The page header is the bar's title, so the phone suite still sees it.
752
+ // Above a phone it stays above the invoice, and the bar is hidden.
753
+ const placeHeader = () => {
754
+ const end = nav.record.querySelector('.fx-record__end');
755
+ if (!isPhone()) {
756
+ slot.hidden = false;
757
+ titleNode.removeAttribute('title');
758
+ if (header.parentElement === nav.record) nav.pager.after(header);
759
+ return;
760
+ }
761
+ slot.hidden = true;
762
+ titleNode.title = titleNode.textContent;
763
+ if (header.parentElement !== nav.record) nav.record.insertBefore(header, end);
764
+ };
765
+ const placeMenu = () => {
766
+ const skip = new Set([primaryOf()].filter(Boolean));
767
+ items.length = 0;
768
+ if (isPhone()) for (const entry of menuOf(skip)) if (entry) items.push(entry);
769
+ menuButton.hidden = items.length === 0;
770
+ };
771
+ const placePrimary = home ? watchPhonePrimary(home, foot, primaryOf) : () => {};
772
+ const sync = () => { placeHeader(); placeMenu(); placePrimary(); };
773
+ sync();
774
+ queueMicrotask(sync);
775
+ if (typeof matchMedia === 'function') matchMedia(WIDTHS.phone).addEventListener('change', sync);
776
+ return { pager: nav.pager, record: nav.record, foot, menuPanel, sync };
608
777
  }
609
778
 
610
779
  // After a send, an Approve or a Save that makes a Pending invoice Ready, the
@@ -910,7 +1079,11 @@ function draftView(invoice, ui, context, redraw) {
910
1079
  }, 'Save');
911
1080
  const opened = JSON.stringify(body());
912
1081
  const changed = () => !isNew && JSON.stringify(body()) !== opened;
913
- const watchChanges = () => { if (!keepsSave) saveButton.hidden = !changed(); };
1082
+ let syncRecord = () => {};
1083
+ const watchChanges = () => {
1084
+ if (!keepsSave) saveButton.hidden = !changed();
1085
+ syncRecord();
1086
+ };
914
1087
  // With an accounting system on, a ready invoice is sent there: Flow issues
915
1088
  // it and queues it, the same as Send all ready. With none, Flow issues it
916
1089
  // and keeps it. A Pending draft has no send: fix its problem first.
@@ -934,7 +1107,7 @@ function draftView(invoice, ui, context, redraw) {
934
1107
  toast(`${fresh.number} is on the way to ${to}.`, 'success');
935
1108
  openNext(ui, invoice.id);
936
1109
  } catch (error) {
937
- toast(error.message, 'danger');
1110
+ toast(error.message, 'danger', { stay: true });
938
1111
  }
939
1112
  },
940
1113
  }, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
@@ -981,6 +1154,30 @@ function draftView(invoice, ui, context, redraw) {
981
1154
  // fine too.
982
1155
  const fromJob = isNew && context.canIssue
983
1156
  ? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => pickJob(ui) }, 'From a job') : null;
1157
+ // A draft that was never issued is deleted. An issued invoice, including
1158
+ // one opened for a revision, is voided instead.
1159
+ const deleteButton = !isNew && context.canIssue && invoice.status === 'draft' && invoice.issuedVersion === 0
1160
+ ? h('button', {
1161
+ class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
1162
+ onclick: async () => {
1163
+ const ok = await ui.dialog({
1164
+ title: 'Delete this invoice?',
1165
+ body: h('p', {}, invoice.job
1166
+ ? 'It was never issued. The job can be invoiced again, and the rules decide.'
1167
+ : 'It was never issued, so it is removed.'),
1168
+ confirm: 'Delete', tone: 'danger',
1169
+ });
1170
+ if (!ok) return;
1171
+ try {
1172
+ await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
1173
+ toast('Invoice deleted.', 'success');
1174
+ sessionStorage.setItem(NAV_BACK, '1');
1175
+ go('/invoices');
1176
+ } catch (error) {
1177
+ toast(error.message, 'danger');
1178
+ }
1179
+ },
1180
+ }, 'Delete') : null;
984
1181
 
985
1182
  // A draft with no problem is in Ready by itself; one with a problem is
986
1183
  // Pending. A changed job is fixed at its source or accepted here.
@@ -1002,11 +1199,25 @@ function draftView(invoice, ui, context, redraw) {
1002
1199
  if (banner) boxes[name].setAttribute('aria-describedby', banner.id);
1003
1200
  }
1004
1201
 
1202
+ const previewBtn = isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.');
1203
+ const showAccept = Boolean(jobChange?.canAccept && context.canIssue);
1204
+ const header = ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
1205
+ actions: [fromJob, deleteButton, previewBtn, saveButton, sendButton ?? issueButton].filter(Boolean),
1206
+ });
1207
+ const primaryOf = () => (showAccept ? null : (sendButton ?? issueButton ?? (saveButton.hidden ? null : saveButton)));
1208
+ const chrome = mountRecord(ui, invoice, header, primaryOf, (skip) => [
1209
+ menuEntry(fromJob, 'briefcase', skip),
1210
+ menuEntry(deleteButton, 'trash-2', skip),
1211
+ menuEntry(previewBtn, 'eye', skip),
1212
+ menuEntry(saveButton, 'check', skip),
1213
+ menuEntry(sendButton, 'mail', skip),
1214
+ menuEntry(issueButton, 'check', skip),
1215
+ ]);
1216
+ syncRecord = chrome.sync;
1005
1217
  return [
1006
- navBar(ui, invoice),
1007
- ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
1008
- actions: [fromJob, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton].filter(Boolean),
1009
- }),
1218
+ chrome.record,
1219
+ chrome.pager,
1220
+ header,
1010
1221
  draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
1011
1222
  ...problems,
1012
1223
  h('div', { class: 'fx-invoice__grid' },
@@ -1027,6 +1238,8 @@ function draftView(invoice, ui, context, redraw) {
1027
1238
  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'),
1028
1239
  totalsHost))),
1029
1240
  h('aside', { class: 'fx-invoice__aside' }, linkedCard(ui, invoice))),
1241
+ chrome.foot,
1242
+ chrome.menuPanel,
1030
1243
  ].map((part) => {
1031
1244
  // Any edit on the page shows Save changes; undoing it hides it again.
1032
1245
  for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
@@ -1103,20 +1316,27 @@ function issuedView(invoice, ui, context, redraw) {
1103
1316
  h('p', {}, 'The number stays used. A void invoice cannot be used again.'),
1104
1317
  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);
1105
1318
  };
1106
- const actions = invoice.status === 'issued' && context.canIssue ? [
1107
- h('button', {
1108
- class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
1109
- onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
1110
- }, 'Void'),
1111
- h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
1112
- ] : [];
1319
+ const voidButton = invoice.status === 'issued' && context.canIssue ? h('button', {
1320
+ class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
1321
+ onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
1322
+ }, 'Void') : null;
1323
+ const reviseButton = invoice.status === 'issued' && context.canIssue
1324
+ ? h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise') : null;
1325
+ const preview = previewButton(ui, invoice.id);
1326
+ const header = h('header', { class: 'fx-ph' },
1327
+ h('div', { class: 'fx-ph__text' },
1328
+ h('h1', { class: 'fx-title' }, invoice.number),
1329
+ h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
1330
+ h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean)));
1331
+ const chrome = mountRecord(ui, invoice, header, () => send, (skip) => [
1332
+ menuEntry(voidButton, 'ban', skip),
1333
+ menuEntry(reviseButton, 'pencil', skip),
1334
+ menuEntry(preview, 'eye', skip),
1335
+ ]);
1113
1336
  return [
1114
- navBar(ui, invoice),
1115
- h('header', { class: 'fx-ph' },
1116
- h('div', { class: 'fx-ph__text' },
1117
- h('h1', { class: 'fx-title' }, invoice.number),
1118
- h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
1119
- h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
1337
+ chrome.record,
1338
+ chrome.pager,
1339
+ header,
1120
1340
  h('div', { class: 'fx-invoice__grid' },
1121
1341
  h('div', { class: 'fx-invoice__main' },
1122
1342
  h('section', { class: 'fx-card fx-doc' },
@@ -1142,6 +1362,8 @@ function issuedView(invoice, ui, context, redraw) {
1142
1362
  h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
1143
1363
  h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
1144
1364
  h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
1365
+ chrome.foot,
1366
+ chrome.menuPanel,
1145
1367
  ];
1146
1368
  }
1147
1369
 
@@ -192,3 +192,13 @@ export function matches(job, q) {
192
192
  return [job.caseRef, job.sourceJobRef, job.clientName, job.workClientName, job.practitioner, job.obligation?.invoiceNumber]
193
193
  .some((value) => value && String(value).toLowerCase().includes(needle));
194
194
  }
195
+
196
+ // How long a press on a phone card is held before Move to opens.
197
+ export const PHONE_HOLD_MS = 500;
198
+
199
+ // The lane showing when one lane fills the phone and the board snaps.
200
+ // A lane just past halfway counts as the next. `count` is how many lanes.
201
+ export function phoneLaneIndex(scrollLeft, laneWidth, count) {
202
+ if (!(laneWidth > 0) || !(count > 0)) return 0;
203
+ return Math.max(0, Math.min(count - 1, Math.round(scrollLeft / laneWidth)));
204
+ }
@@ -5,6 +5,7 @@
5
5
  // and a recheck button that reads the job from the work system again. Flow
6
6
  // never writes to the work system. Kept apart from
7
7
  // invoicing.js so tests can load it without a browser.
8
+ import { applyPhoneRule } from './mobile.js';
8
9
 
9
10
  // Rows shown before "Show all".
10
11
  export const FIRST_ROWS = 5;
@@ -55,7 +56,7 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
55
56
  }, `Open in ${system} ↗`)
56
57
  : null;
57
58
  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
+ ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', 'data-action': 'accept', title: `This invoice takes the Now in ${system} column.`, onclick: onAccept }, 'Accept change')
59
60
  : null;
60
61
  const recheck = onRecheck
61
62
  ? h('button', {
@@ -77,7 +78,7 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
77
78
  const more = rows.length > FIRST_ROWS
78
79
  ? h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: (event) => { draw(true); event.currentTarget.remove(); } }, `Show all ${rows.length}`)
79
80
  : null;
80
- return h('section', { class: 'fx-change', role: 'alert' },
81
+ return applyPhoneRule(h('section', { class: 'fx-change', role: 'alert' },
81
82
  h('div', { class: 'fx-change__head' },
82
83
  h('p', {}, h('strong', {}, 'Problem.'), ' ', changeSummary(change, rows.filter((row) => row.field !== 'Total').length)),
83
84
  h('div', { class: 'fx-change__acts' }, link, accept, recheck)),
@@ -85,7 +86,7 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
85
86
  rows.length ? h('div', { class: 'fx-change__scroll' }, h('table', { class: 'fx-change__table' },
86
87
  h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, 'Field'), h('th', { scope: 'col' }, 'Was'), h('th', { scope: 'col' }, `Now in ${system}`))),
87
88
  body)) : null,
88
- more);
89
+ more), 'jobChangeBanner');
89
90
  }
90
91
 
91
92
  // The banner with Accept change and the recheck wired, for the invoice page