@arcforgelabs/flow 2026.10.46 → 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.
@@ -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',
@@ -259,17 +279,20 @@ export async function renderInvoiceList(main, ui) {
259
279
  }
260
280
 
261
281
  // 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) {
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) {
264
285
  try {
265
286
  const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload) });
266
287
  toast(...done(res));
267
288
  state.ticked.clear();
268
289
  seen.clear();
269
- await load();
290
+ const tab = then(res);
291
+ if (tab && tab !== state.status) openTab(tab);
292
+ else await load();
270
293
  alerts.refresh();
271
294
  } catch (error) {
272
- toast(error.message, 'danger');
295
+ toast(error.message, 'danger', { stay: true });
273
296
  }
274
297
  }
275
298
 
@@ -285,15 +308,25 @@ export async function renderInvoiceList(main, ui) {
285
308
  status.push({
286
309
  icon: to ? 'send' : 'circle-check-big', label: to ? `Send to ${to}` : 'Approve', primary: true,
287
310
  title: to ? `Send to ${to}` : 'Approve',
288
- run: async () => {
311
+ run: async (working) => {
289
312
  const ok = await ui.dialog({
290
313
  title: to ? `Send ${plural(ticked.length, 'invoice', 'invoices')} to ${to}?` : `Approve ${plural(ticked.length, 'invoice', 'invoices')}?`,
291
314
  body: h('p', {}, to ? sendModeNote(to, body.sendMode, 'each one') : 'Each one gets the next number and is locked.'),
292
315
  confirm: to ? `Send ${ticked.length}` : `Approve ${ticked.length}`,
293
316
  });
294
317
  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']);
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));
297
330
  },
298
331
  });
299
332
  }
@@ -301,7 +334,11 @@ export async function renderInvoiceList(main, ui) {
301
334
  if (state.status === 'pending' && context.canIssue && to && failed.length) {
302
335
  status.push({
303
336
  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']),
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
+ },
305
342
  });
306
343
  }
307
344
  apply.push({
@@ -318,6 +355,15 @@ export async function renderInvoiceList(main, ui) {
318
355
  run: () => voidAll(voidable),
319
356
  });
320
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
+ }
321
367
  const all = body.invoices.map((i) => i.id);
322
368
  const selection = [
323
369
  { icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
@@ -393,6 +439,29 @@ export async function renderInvoiceList(main, ui) {
393
439
  alerts.refresh();
394
440
  }
395
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
+
396
465
  // A tab switch shows at once: the tab is selected, the last list seen for
397
466
  // it (if any) is drawn and dimmed, and the fresh list replaces it. Only the
398
467
  // newest request draws, so quick clicks never show an older tab's list.
@@ -402,7 +471,7 @@ export async function renderInvoiceList(main, ui) {
402
471
  const query = new URLSearchParams({
403
472
  status: state.status, offset: String(state.offset), limit: String(state.limit), today: today(),
404
473
  ...(state.q ? { q: state.q } : {}),
405
- ...(filterCount(state.view, filterKind) ? { filter: JSON.stringify(state.view) } : {}),
474
+ ...(filterCount(state.view, kindOf) ? { filter: JSON.stringify(state.view) } : {}),
406
475
  });
407
476
  const key = String(query);
408
477
  const mine = ++asked;
@@ -429,6 +498,19 @@ export async function renderInvoiceList(main, ui) {
429
498
  host.removeAttribute('aria-busy');
430
499
  delete host.dataset.stale;
431
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);
432
514
  }
433
515
 
434
516
  function drawTabs(counts) {
@@ -459,7 +541,7 @@ export async function renderInvoiceList(main, ui) {
459
541
  drawTabs(body.counts);
460
542
  drawBar();
461
543
  if (!body.invoices.length) {
462
- host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, filterKind)
544
+ host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, kindOf)
463
545
  ? 'No invoice in this tab passes the filter.'
464
546
  : state.q
465
547
  ? 'No invoice matches that search.'
@@ -521,7 +603,7 @@ export async function renderInvoiceList(main, ui) {
521
603
  const href = (invoice) => `/invoices/${invoice.id}`;
522
604
  const ALL = {
523
605
  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')) },
606
+ client: { kind: 'client', phone: 'title', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
525
607
  reference: showProblem
526
608
  ? { label: 'Problem', kind: 'text', cell: problemCell }
527
609
  : { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
@@ -596,26 +678,37 @@ function saveNav(nav) {
596
678
  // The bar above one invoice: Back to the list as it was, and the previous and
597
679
  // next invoice of that list. An invoice opened from elsewhere (the job board,
598
680
  // search, a link) steps through its own tab, read once here.
599
- function navBar(ui, invoice) {
681
+ function navBar(ui, invoice, title = 'Invoice') {
600
682
  const { h, go, flow } = ui;
601
683
  const id = invoice?.id ?? null;
602
684
  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'));
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 });
613
695
  const fill = (ids) => {
614
696
  const at = ids.indexOf(id);
615
697
  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')));
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
+ });
619
712
  return true;
620
713
  };
621
714
  const tab = invoice?.tab ?? null;
@@ -627,7 +720,60 @@ function navBar(ui, invoice) {
627
720
  try { sessionStorage.setItem('flow_invoice_tab', tab); } catch { /* Back opens the last tab */ }
628
721
  }).catch(() => { /* Back only */ });
629
722
  }
630
- 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 };
631
777
  }
632
778
 
633
779
  // After a send, an Approve or a Save that makes a Pending invoice Ready, the
@@ -933,7 +1079,11 @@ function draftView(invoice, ui, context, redraw) {
933
1079
  }, 'Save');
934
1080
  const opened = JSON.stringify(body());
935
1081
  const changed = () => !isNew && JSON.stringify(body()) !== opened;
936
- const watchChanges = () => { if (!keepsSave) saveButton.hidden = !changed(); };
1082
+ let syncRecord = () => {};
1083
+ const watchChanges = () => {
1084
+ if (!keepsSave) saveButton.hidden = !changed();
1085
+ syncRecord();
1086
+ };
937
1087
  // With an accounting system on, a ready invoice is sent there: Flow issues
938
1088
  // it and queues it, the same as Send all ready. With none, Flow issues it
939
1089
  // and keeps it. A Pending draft has no send: fix its problem first.
@@ -957,7 +1107,7 @@ function draftView(invoice, ui, context, redraw) {
957
1107
  toast(`${fresh.number} is on the way to ${to}.`, 'success');
958
1108
  openNext(ui, invoice.id);
959
1109
  } catch (error) {
960
- toast(error.message, 'danger');
1110
+ toast(error.message, 'danger', { stay: true });
961
1111
  }
962
1112
  },
963
1113
  }, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
@@ -1004,6 +1154,30 @@ function draftView(invoice, ui, context, redraw) {
1004
1154
  // fine too.
1005
1155
  const fromJob = isNew && context.canIssue
1006
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;
1007
1181
 
1008
1182
  // A draft with no problem is in Ready by itself; one with a problem is
1009
1183
  // Pending. A changed job is fixed at its source or accepted here.
@@ -1025,11 +1199,25 @@ function draftView(invoice, ui, context, redraw) {
1025
1199
  if (banner) boxes[name].setAttribute('aria-describedby', banner.id);
1026
1200
  }
1027
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;
1028
1217
  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
- }),
1218
+ chrome.record,
1219
+ chrome.pager,
1220
+ header,
1033
1221
  draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
1034
1222
  ...problems,
1035
1223
  h('div', { class: 'fx-invoice__grid' },
@@ -1050,6 +1238,8 @@ function draftView(invoice, ui, context, redraw) {
1050
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'),
1051
1239
  totalsHost))),
1052
1240
  h('aside', { class: 'fx-invoice__aside' }, linkedCard(ui, invoice))),
1241
+ chrome.foot,
1242
+ chrome.menuPanel,
1053
1243
  ].map((part) => {
1054
1244
  // Any edit on the page shows Save changes; undoing it hides it again.
1055
1245
  for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
@@ -1126,20 +1316,27 @@ function issuedView(invoice, ui, context, redraw) {
1126
1316
  h('p', {}, 'The number stays used. A void invoice cannot be used again.'),
1127
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);
1128
1318
  };
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
- ] : [];
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
+ ]);
1136
1336
  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)),
1337
+ chrome.record,
1338
+ chrome.pager,
1339
+ header,
1143
1340
  h('div', { class: 'fx-invoice__grid' },
1144
1341
  h('div', { class: 'fx-invoice__main' },
1145
1342
  h('section', { class: 'fx-card fx-doc' },
@@ -1165,6 +1362,8 @@ function issuedView(invoice, ui, context, redraw) {
1165
1362
  h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
1166
1363
  h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
1167
1364
  h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
1365
+ chrome.foot,
1366
+ chrome.menuPanel,
1168
1367
  ];
1169
1368
  }
1170
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
@@ -304,7 +304,9 @@ export function filterJobs(jobs, view, { valueOf, kindOf = (field) => FILTER_FIE
304
304
  const live = (f) => kindOf(f.field) && Object.hasOwn(OPERATORS[kindOf(f.field)], f.op) && filterIsOn(f, kindOf(f.field));
305
305
  const groups = (view.groups ?? []).map((g) => ({ match: g.match, rows: g.filters.filter(live) })).filter((g) => g.rows.length);
306
306
  if (!groups.length) return [...jobs];
307
- const pass = (job) => (f) => matchesFilter(f, valueOf(job, f.field), { today, me, kind: kindOf(f.field), late: f.field === 'due' ? () => late(job) : null });
307
+ // `late` is a job's own lateness (Jobs). Pass null when "due" is a date on
308
+ // the row, so Overdue means that day is before today (Invoices).
309
+ const pass = (job) => (f) => matchesFilter(f, valueOf(job, f.field), { today, me, kind: kindOf(f.field), late: f.field === 'due' && late ? () => late(job) : null });
308
310
  const passGroup = (job) => (g) => (g.match === 'any' ? g.rows.some(pass(job)) : g.rows.every(pass(job)));
309
311
  return jobs.filter((job) => (view.match === 'any' ? groups.some(passGroup(job)) : groups.every(passGroup(job))));
310
312
  }