@arcforgelabs/flow 2026.10.34 → 2026.10.35

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.
@@ -7,6 +7,7 @@
7
7
  // (server/api/invoicing.js). app.js passes in its shared pieces as `ui`.
8
8
  import { alertStrip } from '/assets/ui/alerts.js';
9
9
  import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
10
+ import { jobChangeBanner } from '/assets/ui/job-change.js';
10
11
 
11
12
  const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
12
13
  const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
@@ -21,7 +22,7 @@ const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_che
21
22
  const EMPTY = {
22
23
  to_check: 'No drafts to check. Press New invoice to start one.',
23
24
  problem: 'No problems.',
24
- ready: 'Nothing is ready. Tick drafts and press Mark ready.',
25
+ ready: 'Nothing is ready. Tick drafts and press Move to Ready.',
25
26
  };
26
27
  // Tabs whose rows can be ticked, and what the ticked rows can do.
27
28
  const TICKS = { to_check: true, ready: true };
@@ -137,9 +138,9 @@ export async function renderInvoiceList(main, ui) {
137
138
  const ticked = [...state.ticked];
138
139
  const parts = [];
139
140
  if (state.status === 'to_check') {
140
- parts.push(h('p', { class: 'fx-dim' }, ticked.length ? `${plural(ticked.length, 'draft', 'drafts')} ticked.` : 'Check each draft, tick it, then mark it ready.'));
141
+ parts.push(h('p', { class: 'fx-dim' }, ticked.length ? `${plural(ticked.length, 'draft', 'drafts')} ticked.` : 'Check each draft, tick it, then move it to Ready.'));
141
142
  if (ticked.length) {
142
- parts.push(action(`Mark ${ticked.length} ready`, 'mark-ready', () => ({ invoiceIds: ticked }), (res) => (res.refused.length
143
+ parts.push(action(`Move ${ticked.length} to Ready`, 'mark-ready', () => ({ invoiceIds: ticked }), (res) => (res.refused.length
143
144
  ? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
144
145
  : [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
145
146
  }
@@ -151,7 +152,7 @@ export async function renderInvoiceList(main, ui) {
151
152
  (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
152
153
  }
153
154
  if (context.canIssue && body.counts.ready > 0) {
154
- buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Issue all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
155
+ buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Finalise all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
155
156
  (res) => [`${res.issued} issued.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
156
157
  }
157
158
  if (buttons.length) parts.push(h('div', { class: 'fx-sendbar__actions' }, buttons));
@@ -305,12 +306,12 @@ function saveNav(nav) {
305
306
  }
306
307
 
307
308
  // The bar above one invoice: Back to the list as it was, and the previous and
308
- // next invoice of that list.
309
- function navBar(ui, id) {
310
- const { h, go } = ui;
311
- const nav = readNav();
312
- const ids = nav?.ids ?? [];
313
- const at = ids.indexOf(id);
309
+ // next invoice of that list. An invoice opened from elsewhere (the job board,
310
+ // search, a link) steps through its own tab: Drafts, Ready, Problem, Issued
311
+ // or Void, read once here.
312
+ function navBar(ui, invoice) {
313
+ const { h, go, flow } = ui;
314
+ const id = invoice?.id ?? null;
314
315
  const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
315
316
  const step = (to, label, path) => {
316
317
  const button = h('button', {
@@ -320,15 +321,33 @@ function navBar(ui, id) {
320
321
  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>`;
321
322
  return button;
322
323
  };
323
- return h('nav', { class: 'fx-pager', 'aria-label': 'Invoices' },
324
- h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back'),
325
- at >= 0 ? h('span', { class: 'fx-pager__count' }, `${at + 1} of ${ids.length}`) : null,
326
- at >= 0 ? 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')) : null);
324
+ const bar = h('nav', { class: 'fx-pager', 'aria-label': 'Invoices' },
325
+ h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back'));
326
+ const fill = (ids) => {
327
+ const at = ids.indexOf(id);
328
+ if (at < 0) return false;
329
+ bar.replaceChildren(bar.firstChild,
330
+ h('span', { class: 'fx-pager__count' }, `${at + 1} of ${ids.length}`),
331
+ 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')));
332
+ return true;
333
+ };
334
+ const tab = invoice?.status === 'void' ? 'void' : invoice?.view ?? invoice?.status;
335
+ if (id && !fill(readNav()?.ids ?? []) && TABS.some(([t]) => t === tab)) {
336
+ flow(`/api/invoicing/invoices?${new URLSearchParams({ status: tab })}`).then((body) => {
337
+ const ids = body.invoices.map((i) => i.id);
338
+ if (!fill(ids)) return;
339
+ saveNav({ tab, q: '', ids });
340
+ try { sessionStorage.setItem('flow_invoice_tab', tab); } catch { /* Back opens the last tab */ }
341
+ }).catch(() => { /* Back only */ });
342
+ }
343
+ return bar;
327
344
  }
328
345
 
329
- // After a send, the next invoice of the list opens; at its end, the list.
330
- // The sent invoice stays in the bar's list, so the count holds (2 of 35) and
331
- // Previous opens it again, now sent. Back reloads the list without it.
346
+ // After a send, a Finalise or a Save as draft, the next invoice of the list
347
+ // opens; at its end, the list. So a person reviews drafts one after another
348
+ // without going back. The invoice stays in the bar's list, so the count holds
349
+ // (2 of 35) and Previous opens it again, now done. Back reloads the list
350
+ // without it.
332
351
  export function openNext(ui, id) {
333
352
  const ids = readNav()?.ids ?? [];
334
353
  const at = ids.indexOf(id);
@@ -393,7 +412,7 @@ function draftView(invoice, ui, context, redraw) {
393
412
  const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
394
413
  if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
395
414
  let contact = invoice?.contact ?? null;
396
- // Issue invoice waits for a client and a due date (syncIssue, below).
415
+ // Finalise invoice waits for a client and a due date (syncIssue, below).
397
416
  let onContact = () => {};
398
417
 
399
418
  // Client: type to find a customer.
@@ -545,23 +564,27 @@ function draftView(invoice, ui, context, redraw) {
545
564
  };
546
565
 
547
566
  // Save shows only when there is something to save: a new invoice, or a
548
- // change since the page opened. Saving keeps the invoice where it is.
567
+ // change since the page opened. Save as draft opens the next invoice.
568
+ // Save as draft: a draft that is not ready always offers it. A ready
569
+ // invoice offers Save changes only once something changed.
570
+ const keepsSave = isNew || invoice?.view !== 'ready';
549
571
  const saveButton = h('button', {
550
- class: 'fx-btn', type: 'button', hidden: !isNew,
572
+ class: 'fx-btn', type: 'button', hidden: !keepsSave,
551
573
  onclick: async () => {
552
574
  const saved = await save();
553
575
  if (!saved) return;
554
- toast(isNew ? 'Draft saved.' : 'Changes saved.', 'success');
576
+ toast(keepsSave ? 'Saved as a draft.' : 'Changes saved.', 'success');
555
577
  if (isNew) go(`/invoices/${saved.id}`, { replace: true });
578
+ else if (keepsSave) openNext(ui, invoice.id);
556
579
  else redraw(saved);
557
580
  },
558
- }, isNew ? 'Save draft' : 'Save changes');
581
+ }, keepsSave ? 'Save as draft' : 'Save changes');
559
582
  const opened = JSON.stringify(body());
560
583
  const changed = () => !isNew && JSON.stringify(body()) !== opened;
561
- const watchChanges = () => { if (!isNew) saveButton.hidden = !changed(); };
584
+ const watchChanges = () => { if (!keepsSave) saveButton.hidden = !changed(); };
562
585
  // With an accounting system on, a ready invoice is sent there: Flow issues
563
586
  // it and queues it, the same as Send all ready. With none, Flow issues it
564
- // and keeps it. A draft that is not ready has no send: mark it ready first.
587
+ // and keeps it. A draft that is not ready has no send: move it to Ready first.
565
588
  const to = invoice?.sendsTo ?? context.sendsTo ?? null;
566
589
  const ready = invoice?.view === 'ready';
567
590
  const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
@@ -586,33 +609,36 @@ function draftView(invoice, ui, context, redraw) {
586
609
  }
587
610
  },
588
611
  }, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
612
+ // With no accounting system, Finalise invoice gives the draft its number and
613
+ // locks it. A changed job blocks it, as it blocks Move to Ready.
589
614
  const issueButton = context.canIssue && !to ? h('button', {
590
615
  class: 'fx-btn fx-btn--primary', type: 'button',
591
616
  onclick: async () => {
592
617
  const saved = await save();
593
618
  if (!saved) return;
594
619
  const ok = await ui.dialog({
595
- title: 'Issue this invoice?',
620
+ title: 'Finalise this invoice?',
596
621
  body: h('p', {}, `It gets the next number (${nextNumber}). Flow keeps it exactly as issued. To change it later, revise it.`),
597
- confirm: 'Issue',
622
+ confirm: 'Finalise',
598
623
  });
599
624
  if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
600
625
  try {
601
626
  const { invoice: issued } = await flow(`/api/invoicing/invoices/${saved.id}/issue`, { method: 'POST', body: JSON.stringify({ issueDate: localDate() }) });
602
- toast(`${issued.number} issued.`, 'success');
627
+ toast(`${issued.number} is final.`, 'success');
603
628
  if (isNew) go(`/invoices/${issued.id}`, { replace: true });
604
- else redraw(issued);
629
+ else openNext(ui, invoice.id);
605
630
  } catch (error) {
606
631
  toast(error.message, 'danger');
607
632
  }
608
633
  },
609
- }, 'Issue invoice') : null;
634
+ }, 'Finalise') : null;
610
635
  // Issue needs a client and a due date: until both are set it is greyed.
636
+ // A changed job greys it too, as it does Move to Ready.
611
637
  const syncIssue = () => {
612
638
  if (!issueButton) return;
613
639
  const missing = [contact ? null : 'a client', dueDate.value ? null : 'a due date'].filter(Boolean);
614
- issueButton.disabled = missing.length > 0;
615
- issueButton.title = missing.length ? `Choose ${missing.join(' and ')} first.` : '';
640
+ issueButton.disabled = missing.length > 0 || Boolean(invoice?.jobChange);
641
+ issueButton.title = invoice?.jobChange ? 'Fix the problem first.' : missing.length ? `Choose ${missing.join(' and ')} first.` : 'Gives it the next number and locks it. Nothing is sent.';
616
642
  };
617
643
  onContact = syncIssue;
618
644
  clientInput.addEventListener('input', syncIssue);
@@ -630,23 +656,33 @@ function draftView(invoice, ui, context, redraw) {
630
656
  h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
631
657
  h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
632
658
 
633
- // Mark ready: the draft is saved, then Flow checks it; one with a problem
634
- // stays a draft. Move to Drafts, on the Status card, undoes it.
635
- const markReadyButton = isNew || ready ? null : h('button', {
636
- class: 'fx-btn', type: 'button',
659
+ // Finalise, with an accounting system on: the draft is saved, then Flow
660
+ // checks it and moves it to the Ready tab, for Send all ready; nothing is
661
+ // sent here. A draft the draft routine made from a job ready to invoice is
662
+ // in Ready already (automations.js), so this is for the rest. One with a
663
+ // problem stays a draft. Move to Drafts, on the Status card, undoes it. A
664
+ // changed job is fixed at its source or adopted, so the button waits.
665
+ const jobChange = invoice?.jobChange ?? null;
666
+ const markReadyButton = isNew || ready || !to ? null : h('button', {
667
+ class: 'fx-btn fx-btn--primary', type: 'button', disabled: Boolean(jobChange),
668
+ title: jobChange ? 'Fix the problem first.' : `Checks this draft and moves it to Ready. Send all ready sends it to ${to}.`,
637
669
  onclick: async () => {
638
670
  if (!(await save())) return;
639
671
  try {
640
672
  const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
641
- if (res.marked) toast('Ready to send.', 'success');
642
- else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
673
+ if (res.marked) {
674
+ toast(`Final. It is in Ready; Send all ready sends it to ${to}.`, 'success');
675
+ openNext(ui, invoice.id);
676
+ return;
677
+ }
678
+ toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
643
679
  const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
644
680
  redraw(fresh);
645
681
  } catch (error) {
646
682
  toast(error.message, 'danger');
647
683
  }
648
684
  },
649
- }, 'Mark ready');
685
+ }, 'Finalise');
650
686
  // A ready invoice can go back to Drafts. It is a status change, so it sits
651
687
  // with the status, not with the page's main buttons.
652
688
  const statusCard = ready ? h('section', { class: 'fx-card fx-side-card' },
@@ -665,18 +701,41 @@ function draftView(invoice, ui, context, redraw) {
665
701
  }
666
702
  },
667
703
  }, 'Move to Drafts')) : null;
668
- const problems = invoice?.problems?.length
669
- ? notice(invoice.problems.map((p) => p.message).join(' '), 'danger', 'Problem.')
670
- : null;
704
+ // The job changed after the invoice was made: its own banner shows what
705
+ // changed. Other problems stay one line.
706
+ const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
707
+ const problems = [
708
+ jobChange ? jobChangeBanner(ui, jobChange, {
709
+ money: formatCents,
710
+ // Adopting changes the draft for the business: owners and admins only.
711
+ onAdopt: !context.canIssue ? null : async () => {
712
+ const system = jobChange.systemName;
713
+ const ok = await ui.dialog({
714
+ title: `Adopt the ${system} changes?`,
715
+ body: h('p', {}, `This invoice takes the Now in ${system} column.${changed() ? ' Your unsaved changes here are lost.' : ''} Nothing goes to ${system} or the accounting system.`),
716
+ confirm: 'Adopt changes',
717
+ });
718
+ if (!ok) return;
719
+ try {
720
+ const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body: '{}' });
721
+ toast(`The invoice now matches ${system}.${res.invoice.view === 'ready' ? ' It is in Ready.' : ''}`, 'success');
722
+ redraw(res.invoice);
723
+ } catch (error) {
724
+ toast(error.message, 'danger');
725
+ }
726
+ },
727
+ }) : null,
728
+ otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
729
+ ];
671
730
 
672
731
  return [
673
- navBar(ui, invoice?.id ?? null),
732
+ navBar(ui, invoice),
674
733
  h('header', { class: 'fx-ph' },
675
734
  h('div', { class: 'fx-ph__text' },
676
735
  h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
677
736
  h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
678
- h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), sendButton ?? issueButton)),
679
- problems,
737
+ h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, markReadyButton, sendButton ?? issueButton)),
738
+ ...problems,
680
739
  fromJob,
681
740
  h('div', { class: 'fx-invoice__grid' },
682
741
  h('div', { class: 'fx-invoice__main' },
@@ -778,7 +837,7 @@ function issuedView(invoice, ui, context, redraw) {
778
837
  h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
779
838
  ] : [];
780
839
  return [
781
- navBar(ui, invoice.id),
840
+ navBar(ui, invoice),
782
841
  h('header', { class: 'fx-ph' },
783
842
  h('div', { class: 'fx-ph__text' },
784
843
  h('div', { class: 'fx-row' },
@@ -91,7 +91,7 @@ function reasonFix(reason, acct) {
91
91
  if (reason === 'the work system report is out of date') {
92
92
  return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.' };
93
93
  }
94
- if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Compare the invoice with the job, then correct the invoice. If only the job changed, use its new lines.');
94
+ if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Open the invoice to see what changed. Fix the job in the work system, or adopt its changes on the draft.');
95
95
  if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.');
96
96
  if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.');
97
97
  if (/^the accounting evidence is ambiguous/.test(reason)) return fix(`${acct} match unclear`, `Find the invoice for this job in ${acct}, then link it.`);
@@ -0,0 +1,78 @@
1
+ // The "job changed" problem on a draft invoice (server/core/ledger.js,
2
+ // jobChangeFor): what the job was when the invoice was made and what the
3
+ // work system has now, one row per changed fact, with "Open in <system>" to
4
+ // fix it there and "Adopt <system> changes" to take the new values into
5
+ // Flow's own draft. Flow never writes to the work system. Kept apart from
6
+ // invoicing.js so tests can load it without a browser.
7
+
8
+ // Rows shown before "Show all".
9
+ export const FIRST_ROWS = 5;
10
+
11
+ const lineText = (line) => [line.code, line.description].filter(Boolean).join(' · ');
12
+ const lineFull = (line, money) => `${lineText(line)} · ${line.quantity} × ${line.unitAmountCents == null ? '—' : money(line.unitAmountCents)}`;
13
+
14
+ // [{ field, was, now }] in plain words. `money` formats cents.
15
+ export function changeRows(change, money) {
16
+ const rows = [];
17
+ if (change.contact) rows.push({ field: 'Client', was: change.contact.was ?? '—', now: change.contact.now ?? '—' });
18
+ for (const line of change.lines ?? []) {
19
+ const at = `Line ${line.position}`;
20
+ if (line.change === 'added') { rows.push({ field: `${at} · added`, was: '—', now: lineFull(line.now, money) }); continue; }
21
+ if (line.change === 'removed') { rows.push({ field: `${at} · removed`, was: lineFull(line.was, money), now: '—' }); continue; }
22
+ if (line.fields.includes('item')) rows.push({ field: `${at} · item`, was: lineText(line.was), now: lineText(line.now) });
23
+ if (line.fields.includes('description')) rows.push({ field: `${at} · description`, was: line.was.description, now: line.now.description });
24
+ if (line.fields.includes('quantity')) rows.push({ field: `${at} · qty`, was: String(line.was.quantity), now: String(line.now.quantity), num: true });
25
+ if (line.fields.includes('price')) {
26
+ rows.push({ field: `${at} · price`, was: line.was.unitAmountCents == null ? '—' : money(line.was.unitAmountCents), now: line.now.unitAmountCents == null ? '—' : money(line.now.unitAmountCents), num: true });
27
+ }
28
+ }
29
+ const { was, now } = change.total ?? {};
30
+ if (was != null && now != null && was !== now) rows.push({ field: 'Total', was: money(was), now: money(now), num: true });
31
+ return rows;
32
+ }
33
+
34
+ // The sentence after "Problem." One fact per sentence.
35
+ export function changeSummary(change, rowCount) {
36
+ const system = change.systemName ?? 'the work system';
37
+ const count = `${rowCount} ${rowCount === 1 ? 'change' : 'changes'}.`;
38
+ if (change.canAdopt) return `The job changed in ${system} after this invoice was made. ${count}`;
39
+ if (change.sent) return `The job changed in ${system} after this invoice was sent. ${count} Fix it in ${system} or in the accounting system.`;
40
+ return `The job changed in ${system}, and this draft changed too. ${count} Fix it in ${system} or in this draft.`;
41
+ }
42
+
43
+ // The banner. `onAdopt` runs "Adopt changes"; it is shown only when only the
44
+ // work system changed and the draft is not sent (ledger.js, outcome offer).
45
+ export function jobChangeBanner(ui, change, { money, onAdopt = null }) {
46
+ const { h } = ui;
47
+ const system = change.systemName ?? 'the work system';
48
+ const rows = changeRows(change, money);
49
+ const order = change.sourceInvoiceRef ? `Order #${change.sourceInvoiceRef}` : null;
50
+ const link = change.link
51
+ ? h('a', {
52
+ class: 'fx-btn fx-btn--sm', href: change.link.url, target: '_blank', rel: 'noopener noreferrer',
53
+ title: change.link.exact ? `Opens this job in ${system}.` : `Opens ${system}. Find ${order ?? 'the job'} there.`,
54
+ }, `Open in ${system} ↗`)
55
+ : null;
56
+ const adopt = change.canAdopt && onAdopt
57
+ ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', onclick: onAdopt }, `Adopt ${system} changes`)
58
+ : null;
59
+ const body = h('tbody', {});
60
+ const draw = (all) => body.replaceChildren(...(all ? rows : rows.slice(0, FIRST_ROWS)).map((row) => h('tr', {},
61
+ h('th', { scope: 'row' }, row.field),
62
+ h('td', { class: `fx-change__was${row.num ? ' fx-num' : ''}` }, row.was),
63
+ h('td', { class: `fx-change__now${row.num ? ' fx-num' : ''}` }, row.now))));
64
+ draw(false);
65
+ const more = rows.length > FIRST_ROWS
66
+ ? h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: (event) => { draw(true); event.currentTarget.remove(); } }, `Show all ${rows.length}`)
67
+ : null;
68
+ return h('section', { class: 'fx-change', role: 'alert' },
69
+ h('div', { class: 'fx-change__head' },
70
+ h('p', {}, h('strong', {}, 'Problem.'), ' ', changeSummary(change, rows.filter((row) => row.field !== 'Total').length)),
71
+ h('div', { class: 'fx-change__acts' }, link, adopt)),
72
+ adopt ? h('p', { class: 'fx-change__note' }, `Adopt: this invoice takes the Now in ${system} column. Nothing goes to ${system} or the accounting system.`) : null,
73
+ !change.link && order ? h('p', { class: 'fx-change__note' }, `Find ${order} in ${system} to fix it there.`) : null,
74
+ rows.length ? h('div', { class: 'fx-change__scroll' }, h('table', { class: 'fx-change__table' },
75
+ h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, 'Field'), h('th', { scope: 'col' }, 'Was'), h('th', { scope: 'col' }, `Now in ${system}`))),
76
+ body)) : null,
77
+ more);
78
+ }
@@ -0,0 +1,119 @@
1
+ // A person's own view of the Jobs page: layout, group, sort and filter. It is
2
+ // saved with their account (PUT /api/me/views/jobs, server/auth/people.js),
3
+ // so it follows them to every device. The rules are here, with no page code,
4
+ // so the server can check a saved view and tests can read them
5
+ // (test/job-view.test.js). server/ui/jobs.js draws the page.
6
+
7
+ export const LAYOUTS = ['board', 'list'];
8
+ export const GROUPS = { status: 'Status', stage: 'Invoice stage', client: 'Client', practitioner: 'Practitioner' };
9
+ export const SORTS = { board: 'Lab system order', due: 'Date', client: 'Client', practitioner: 'Practitioner', total: 'Total' };
10
+ export const DIRS = ['asc', 'desc'];
11
+ export const DEFAULT_VIEW = Object.freeze({ layout: 'board', group: 'status', sort: 'board', dir: 'asc', hide: [], late: false, problems: false });
12
+
13
+ // Group by one of the lab's custom fields (docs/CUSTOM_FIELDS.md):
14
+ // 'field:<field id>'. Only dropdown, person and checkbox fields make lanes.
15
+ const FIELD_GROUP = /^field:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
+ export const fieldGroup = (fieldId) => `field:${fieldId}`;
17
+ export const fieldIdOf = (group) => (FIELD_GROUP.test(group ?? '') ? group.slice(6) : null);
18
+
19
+ const MAX_HIDDEN = 100;
20
+ const MAX_LABEL = 120;
21
+
22
+ // A view as Flow stores it: only the known keys, each checked. Anything else
23
+ // falls back to the default, so a bad saved view never breaks the page.
24
+ export function normaliseJobsView(input) {
25
+ const value = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
26
+ const pick = (key, allowed) => (allowed.includes(value[key]) ? value[key] : DEFAULT_VIEW[key]);
27
+ const hide = Array.isArray(value.hide)
28
+ ? [...new Set(value.hide.filter((label) => typeof label === 'string' && label.length > 0 && label.length <= MAX_LABEL))].slice(0, MAX_HIDDEN)
29
+ : [];
30
+ return {
31
+ layout: pick('layout', LAYOUTS),
32
+ group: FIELD_GROUP.test(value.group ?? '') ? value.group : pick('group', Object.keys(GROUPS)),
33
+ sort: pick('sort', Object.keys(SORTS)),
34
+ dir: pick('dir', DIRS),
35
+ hide,
36
+ late: value.late === true,
37
+ problems: value.problems === true,
38
+ };
39
+ }
40
+
41
+ // How many filters are on: shown on the Filter button.
42
+ export function filterCount(view) {
43
+ return view.hide.length + (view.late ? 1 : 0) + (view.problems ? 1 : 0);
44
+ }
45
+
46
+ // The jobs the filter keeps. `status` is a job's status label; `late` and
47
+ // `problem` say if a job is late or has a problem (jobs.js knows how).
48
+ export function filterJobs(jobs, view, { status, late, problem }) {
49
+ const hidden = new Set(view.hide);
50
+ return jobs.filter((job) => !hidden.has(status(job))
51
+ && (!view.late || late(job))
52
+ && (!view.problems || problem(job)));
53
+ }
54
+
55
+ const dayOf = (job) => job.dueOn ?? job.orderedOn ?? job.jobDate ?? null;
56
+ const text = (value) => (value ?? '').toString().toLowerCase();
57
+ const KEYS = {
58
+ due: dayOf,
59
+ client: (job) => text(job.clientName) || null,
60
+ practitioner: (job) => text(job.practitioner) || null,
61
+ total: (job) => job.totalCents ?? null,
62
+ };
63
+
64
+ // The jobs in the view's order. "Lab system order" keeps the order the API
65
+ // gives. A job with no value for the key goes last, in either direction.
66
+ export function sortJobs(jobs, view) {
67
+ const key = KEYS[view.sort];
68
+ if (!key) return view.dir === 'desc' ? [...jobs].reverse() : [...jobs];
69
+ const sign = view.dir === 'desc' ? -1 : 1;
70
+ return jobs.map((job, i) => [job, key(job), i]).sort(([, a, i], [, b, j]) => {
71
+ if (a == null || b == null) return (a == null) - (b == null) || i - j;
72
+ const order = typeof a === 'number' ? a - b : String(a).localeCompare(String(b));
73
+ return order * sign || i - j;
74
+ }).map(([job]) => job);
75
+ }
76
+
77
+ // The jobs in groups: one board column, or one block of the list, per group,
78
+ // in order. `columns` is the group labels in order (all of them, so an empty
79
+ // status still shows); `labelOf` gives a job's label.
80
+ export function groupJobs(jobs, columns, labelOf) {
81
+ const groups = new Map(columns.map((label) => [label, []]));
82
+ for (const job of jobs) {
83
+ const label = labelOf(job);
84
+ if (!groups.has(label)) groups.set(label, []);
85
+ groups.get(label).push(job);
86
+ }
87
+ return [...groups].map(([label, inGroup]) => ({ label, jobs: inGroup }));
88
+ }
89
+
90
+ // The lanes of a custom field, in order: [{ label, value, color }]. `value`
91
+ // is what a card dropped in the lane gets (null clears it). The empty lane
92
+ // comes first, like ClickUp's. An archived option that a job still has gets
93
+ // a lane after the live ones. `people` is [{ id, name }] for a person field;
94
+ // `jobs` are the jobs on the page and `valueOf` reads a job's value.
95
+ export const NOT_SET = 'Not set';
96
+ export function fieldLanes(field, { people = [], jobs = [], valueOf = () => null } = {}) {
97
+ const used = new Set(jobs.map(valueOf).filter((v) => v != null));
98
+ if (field.type === 'checkbox') {
99
+ return [{ label: 'No', value: null, color: 'gray' }, { label: 'Yes', value: true, color: 'green' }];
100
+ }
101
+ const empty = { label: NOT_SET, value: null, color: 'gray' };
102
+ if (field.type === 'person') {
103
+ const known = people.map((p) => ({ label: p.name, value: p.id, color: 'blue' }));
104
+ const gone = [...used].filter((id) => !people.some((p) => p.id === id))
105
+ .map((id, i) => ({ label: `Former person ${i + 1}`, value: id, color: 'gray' }));
106
+ return [empty, ...known, ...gone];
107
+ }
108
+ const options = field.options ?? [];
109
+ return [empty,
110
+ ...options.filter((o) => !o.archived).map((o) => ({ label: o.name, value: o.id, color: o.color ?? 'gray' })),
111
+ ...options.filter((o) => o.archived && used.has(o.id)).map((o) => ({ label: `${o.name} (archived)`, value: o.id, color: 'gray', archived: true }))];
112
+ }
113
+
114
+ // The lane label a job sits in for a field's lanes. A checkbox that is
115
+ // false sits in "No", with the unset ones.
116
+ export function laneOf(lanes, value) {
117
+ if (value === false || value == null) return lanes[0].label;
118
+ return (lanes.find((lane) => lane.value === value) ?? lanes[0]).label;
119
+ }