@arcforgelabs/flow 2026.10.36 → 2026.10.38

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 (38) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/api/custom-fields.js +2 -1
  5. package/server/api/invoicing.js +61 -3
  6. package/server/auth/clerk.js +35 -0
  7. package/server/auth/routes.js +45 -6
  8. package/server/core/custom-fields.js +18 -0
  9. package/server/core/host.js +24 -0
  10. package/server/core/job-board.js +2 -1
  11. package/server/core/job-match.js +241 -0
  12. package/server/core/ledger.js +80 -1
  13. package/server/index.js +9 -3
  14. package/server/integrations/seazona/feed.js +4 -1
  15. package/server/integrations/seazona/index.js +1 -1
  16. package/server/integrations/seazona/settings.js +5 -2
  17. package/server/integrations/sentry/index.js +5 -2
  18. package/server/integrations/xero/fit.js +108 -0
  19. package/server/integrations/xero/import.js +6 -1
  20. package/server/integrations/xero/index.js +4 -4
  21. package/server/integrations/xero/ledger.js +3 -0
  22. package/server/integrations/xero/links.js +293 -0
  23. package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
  24. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  25. package/server/integrations/xero/reconcile.js +17 -11
  26. package/server/integrations/xero/routes.js +42 -0
  27. package/server/integrations/xero/settings.js +12 -5
  28. package/server/ui/app-page.js +1 -1
  29. package/server/ui/app.js +142 -23
  30. package/server/ui/bulk-bar.js +100 -0
  31. package/server/ui/flow.css +225 -11
  32. package/server/ui/invoicing.js +234 -30
  33. package/server/ui/job-board.js +60 -34
  34. package/server/ui/job-view.js +30 -7
  35. package/server/ui/jobs.js +469 -38
  36. package/server/ui/reconcile.js +275 -0
  37. package/server/ui/routes.js +1 -1
  38. package/server/ui/xero-import.js +329 -0
@@ -6,6 +6,7 @@
6
6
  // zero, server/core/money.js). Data comes from /api/invoicing
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
+ import { bulkBar } from '/assets/ui/bulk-bar.js';
9
10
  import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
10
11
  import { jobChangeBanner } from '/assets/ui/job-change.js';
11
12
 
@@ -22,10 +23,10 @@ const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_che
22
23
  const EMPTY = {
23
24
  to_check: 'No drafts to check. Press New invoice to start one.',
24
25
  problem: 'No problems.',
25
- ready: 'Nothing is ready. Tick drafts and press Move to Ready.',
26
+ ready: 'Nothing is ready. In Drafts, tick drafts and press Ready.',
26
27
  };
27
- // Tabs whose rows can be ticked, and what the ticked rows can do.
28
- const TICKS = { to_check: true, ready: true };
28
+ // Every tab's rows can be ticked; the bulk bar (bulkActions) says what the
29
+ // ticked rows can do there.
29
30
 
30
31
  const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
31
32
  export const formatCents = (cents) => money.format((cents ?? 0) / 100);
@@ -120,18 +121,28 @@ export async function renderInvoiceList(main, ui) {
120
121
  const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice', value: state.q });
121
122
  const bar = h('div', { class: 'fx-sendbar', hidden: true });
122
123
  const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
124
+ const bulk = bulkBar(ui);
123
125
  const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
124
126
  let body = null;
125
127
  const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
126
128
  const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
129
+ // The tabs, search and send bar stay at the top while the rows scroll up
130
+ // under them; the bulk bar floats at the foot (ClickUp's layout).
131
+ const head = h('div', { class: 'fx-listhead' }, h('div', { class: 'fx-toolbar' }, tabs, search), bar);
132
+ const edge = h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' });
127
133
  main.append(
128
134
  pageHeader('Invoices', {
129
135
  actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
130
136
  }),
131
137
  alerts.element,
132
- h('div', { class: 'fx-toolbar' }, tabs, search),
133
- bar,
134
- host);
138
+ edge,
139
+ head,
140
+ host,
141
+ bulk.element);
142
+ // A shadow under the head shows once rows scroll under it.
143
+ if (typeof IntersectionObserver === 'function') {
144
+ new IntersectionObserver(([entry]) => head.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${head.offsetTop > 0 && window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1}px 0px 0px 0px` }).observe(edge);
145
+ }
135
146
 
136
147
  let timer;
137
148
  search.addEventListener('input', () => {
@@ -167,19 +178,10 @@ export async function renderInvoiceList(main, ui) {
167
178
  const ticked = [...state.ticked];
168
179
  const parts = [];
169
180
  if (state.status === 'to_check') {
170
- 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.'));
171
- if (ticked.length) {
172
- parts.push(action(`Move ${ticked.length} to Ready`, 'mark-ready', () => ({ invoiceIds: ticked }), (res) => (res.refused.length
173
- ? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
174
- : [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
175
- }
181
+ parts.push(h('p', { class: 'fx-dim' }, 'Check each draft, tick it, then press Ready.'));
176
182
  } else if (state.status === 'ready') {
177
183
  parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
178
184
  const buttons = [];
179
- if (ticked.length) {
180
- buttons.push(action(`Move ${ticked.length} to Drafts`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
181
- (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
182
- }
183
185
  if (context.canIssue && body.counts.ready > 0) {
184
186
  buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Finalise all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
185
187
  (res) => [`${res.issued} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
@@ -194,6 +196,162 @@ export async function renderInvoiceList(main, ui) {
194
196
  }
195
197
  bar.replaceChildren(...parts);
196
198
  bar.hidden = !parts.length;
199
+ bulk.draw({ count: ticked.length, onClear: () => setTicks([]), ...bulkActions(ticked) });
200
+ }
201
+
202
+ // Ticks changed from the bar: the rows draw again with the new ticks.
203
+ function setTicks(ids) {
204
+ state.ticked = new Set(ids);
205
+ if (body) draw(body);
206
+ }
207
+
208
+ // A bulk step on the server: on success the ticks clear and the list loads
209
+ // again; on a failure the ticks stay, so a person can try again.
210
+ async function post(path, payload, done) {
211
+ try {
212
+ const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload) });
213
+ toast(...done(res));
214
+ state.ticked.clear();
215
+ seen.clear();
216
+ await load();
217
+ alerts.refresh();
218
+ } catch (error) {
219
+ toast(error.message, 'danger');
220
+ }
221
+ }
222
+
223
+ // What the ticked rows can do on this tab. The bar shows the main ones;
224
+ // More shows them all, with Selection steps under them.
225
+ function bulkActions(ticked) {
226
+ const to = body.sendsTo;
227
+ const rows = body.invoices.filter((i) => state.ticked.has(i.id));
228
+ const numbered = ['issued', 'void'].includes(state.status);
229
+ const status = [];
230
+ const apply = [];
231
+ if (state.status === 'ready' && context.canIssue) {
232
+ status.push({
233
+ icon: 'circle-check-big', label: 'Finalise', primary: true,
234
+ title: to ? `Finalise and send to ${to}` : 'Finalise',
235
+ run: async () => {
236
+ const ok = await ui.dialog({
237
+ title: `Finalise ${plural(ticked.length, 'invoice', 'invoices')}?`,
238
+ body: h('p', {}, `Each one gets the next number and is locked.${to ? ` ${sendModeNote(to, body.sendMode, 'each one')}` : ''}`),
239
+ confirm: to ? `Finalise and send ${ticked.length}` : `Finalise ${ticked.length}`,
240
+ });
241
+ if (!ok) return;
242
+ await post('send-ready', { issueDate: localDate(), invoiceIds: ticked },
243
+ (res) => [`${plural(res.issued, 'invoice', 'invoices')} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, 'success']);
244
+ },
245
+ });
246
+ }
247
+ if (state.status === 'ready') {
248
+ status.push({
249
+ icon: 'undo-2', label: 'Drafts', title: 'Move to Drafts',
250
+ run: () => post('mark-not-ready', { invoiceIds: ticked }, (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success']),
251
+ });
252
+ }
253
+ if (state.status === 'to_check') {
254
+ status.push({
255
+ icon: 'check-check', label: 'Ready', primary: true, title: 'Move to Ready',
256
+ run: () => post('mark-ready', { invoiceIds: ticked }, (res) => (res.refused.length
257
+ ? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
258
+ : [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])),
259
+ });
260
+ }
261
+ const failed = rows.filter((i) => i.status === 'issued').map((i) => i.id);
262
+ if (state.status === 'problem' && context.canIssue && to && failed.length) {
263
+ status.push({
264
+ icon: 'refresh-cw', label: 'Retry', primary: true, title: `Send again to ${to}`,
265
+ run: () => post('send-again', { invoiceIds: failed }, (res) => [`${res.queued} on the way to ${to} again.`, 'success']),
266
+ });
267
+ }
268
+ apply.push({
269
+ icon: 'copy', label: 'Copy', iconOnly: true, title: numbered ? 'Copy numbers' : 'Copy references',
270
+ run: () => copyLines(rows.map((i) => (numbered ? i.number : i.reference) ?? i.contactName ?? '')),
271
+ });
272
+ apply.push({ icon: 'download', label: 'Export', iconOnly: true, title: 'Export CSV', run: () => exportCsv(rows) });
273
+ if (numbered) apply.push({ icon: 'file-down', label: 'PDFs', iconOnly: true, title: 'Download PDFs', run: () => downloadPdfs(rows) });
274
+ const danger = [];
275
+ const voidable = rows.filter((i) => i.status === 'issued');
276
+ if (state.status === 'issued' && context.canIssue && voidable.length) {
277
+ danger.push({
278
+ icon: 'ban', label: 'Void', iconOnly: true, tone: 'danger', title: 'Void',
279
+ run: () => voidAll(voidable),
280
+ });
281
+ }
282
+ const all = body.invoices.map((i) => i.id);
283
+ const selection = [
284
+ { icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
285
+ { icon: 'square-check', label: 'Invert ticks', run: () => setTicks(all.filter((id) => !state.ticked.has(id))) },
286
+ { icon: 'square-x', label: 'Clear ticks', run: () => setTicks([]) },
287
+ ];
288
+ return {
289
+ groups: [status, apply, danger],
290
+ more: [
291
+ { head: 'Change status', actions: [...status, ...danger] },
292
+ { head: 'Apply an action', actions: apply },
293
+ { head: 'Selection', actions: selection },
294
+ ],
295
+ };
296
+ }
297
+
298
+ async function copyLines(lines) {
299
+ try {
300
+ await navigator.clipboard.writeText(lines.join('\n'));
301
+ toast(`${plural(lines.length, 'line', 'lines')} copied.`, 'success');
302
+ } catch {
303
+ toast('The browser did not allow a copy.', 'danger');
304
+ }
305
+ }
306
+
307
+ // The ticked rows as a CSV file, with the columns on the screen.
308
+ function exportCsv(rows) {
309
+ const cell = (v) => (/[",\n]/.test(String(v ?? '')) ? `"${String(v).replace(/"/g, '""')}"` : String(v ?? ''));
310
+ const lines = [['Number', 'Client', 'Reference', 'Date', 'Total', 'Status']]
311
+ .concat(rows.map((i) => [i.number ?? '', i.contactName ?? '', i.reference ?? '', i.issueDate ?? i.createdAt?.slice(0, 10) ?? '',
312
+ ((i.totalCents ?? 0) / 100).toFixed(2), i.status === 'void' ? 'Void' : VIEW_LABEL[i.view] ?? STATUS_LABEL[i.status]]));
313
+ save(new Blob([lines.map((l) => l.map(cell).join(',')).join('\r\n')], { type: 'text/csv' }), `invoices-${state.status}-${localDate()}.csv`);
314
+ toast(`${plural(rows.length, 'invoice', 'invoices')} exported.`, 'success');
315
+ }
316
+
317
+ async function downloadPdfs(rows) {
318
+ const token = await window.Clerk?.session?.getToken();
319
+ let saved = 0;
320
+ for (const invoice of rows) {
321
+ try {
322
+ const res = await fetch(`/api/invoicing/invoices/${invoice.id}/pdf`, { headers: { Authorization: `Bearer ${token}` } });
323
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
324
+ save(await res.blob(), `${invoice.number ?? invoice.id}.pdf`);
325
+ saved += 1;
326
+ } catch {
327
+ // The toast below counts the ones that did not save.
328
+ }
329
+ }
330
+ toast(saved === rows.length ? `${plural(saved, 'PDF', 'PDFs')} saved.` : `${saved} of ${rows.length} PDFs saved.`, saved === rows.length ? 'success' : 'danger');
331
+ }
332
+
333
+ // Void, one invoice after the other, as on one invoice's page.
334
+ async function voidAll(rows) {
335
+ const ok = await ui.dialog({
336
+ title: `Void ${plural(rows.length, 'invoice', 'invoices')}?`,
337
+ body: h('div', {}, h('p', {}, 'Their numbers stay used. A void invoice cannot be finalised again.'),
338
+ body.sendsTo ? h('p', { class: 'fx-problem' }, `Flow does not change ${body.sendsTo}: void them in ${body.sendsTo} too.`) : null),
339
+ confirm: `Void ${rows.length}`, tone: 'danger',
340
+ });
341
+ if (!ok) return;
342
+ let done = 0;
343
+ let error = null;
344
+ for (const invoice of rows) {
345
+ try {
346
+ await flow(`/api/invoicing/invoices/${invoice.id}/void`, { method: 'POST', body: '{}' });
347
+ done += 1;
348
+ } catch (e) { error = e; }
349
+ }
350
+ toast(error ? `${done} of ${rows.length} void. ${error.message}` : `${plural(done, 'invoice', 'invoices')} void.`, error ? 'danger' : 'success');
351
+ state.ticked.clear();
352
+ seen.clear();
353
+ await load();
354
+ alerts.refresh();
197
355
  }
198
356
 
199
357
  // A tab switch shows at once: the tab is selected, the last list seen for
@@ -257,7 +415,6 @@ export async function renderInvoiceList(main, ui) {
257
415
  saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
258
416
  const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
259
417
  const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
260
- const ticks = TICKS[state.status];
261
418
  const showProblem = state.status === 'problem';
262
419
  const created = ['to_check', 'problem', 'ready'].includes(state.status);
263
420
  // An invoice gets its number when it is sent or issued, so only the
@@ -280,6 +437,7 @@ export async function renderInvoiceList(main, ui) {
280
437
  };
281
438
  const all = h('input', {
282
439
  type: 'checkbox', class: 'fx-tick', 'aria-label': 'Tick every invoice shown',
440
+ ...(state.ticked.size && body.invoices.every((i) => state.ticked.has(i.id)) ? { checked: true } : {}),
283
441
  onchange: () => {
284
442
  for (const invoice of body.invoices) { if (all.checked) state.ticked.add(invoice.id); else state.ticked.delete(invoice.id); }
285
443
  for (const box of boxes) box.checked = all.checked;
@@ -289,7 +447,7 @@ export async function renderInvoiceList(main, ui) {
289
447
  const statusCells = new Map();
290
448
  host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
291
449
  h('thead', {}, h('tr', {},
292
- ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
450
+ h('th', { class: 'fx-tick-cell' }, all),
293
451
  numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
294
452
  h('th', {}, created ? 'Created' : 'Finalised'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'),
295
453
  linked ? h('th', {}, 'Linked') : null)),
@@ -298,7 +456,7 @@ export async function renderInvoiceList(main, ui) {
298
456
  const first = invoice.problems?.[0];
299
457
  const view = invoice.status === 'void' ? null : invoice.view;
300
458
  return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
301
- ticks ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)) : null,
459
+ h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)),
302
460
  numbered ? h('td', {}, link(href, invoice.number ?? 'Draft')) : null,
303
461
  h('td', {}, numbered ? (invoice.contactName ?? '—') : link(href, invoice.contactName ?? 'No client')),
304
462
  showProblem
@@ -336,6 +494,18 @@ export async function renderInvoiceList(main, ui) {
336
494
  if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
337
495
  }
338
496
 
497
+ // A file the browser saves, from a blob made on this page.
498
+ function save(blob, name) {
499
+ const url = URL.createObjectURL(blob);
500
+ const a = document.createElement('a');
501
+ a.href = url;
502
+ a.download = name;
503
+ document.body.append(a);
504
+ a.click();
505
+ a.remove();
506
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
507
+ }
508
+
339
509
  // The list an invoice was opened from: its tab, search, invoice ids in order,
340
510
  // and scroll. The invoice page's bar steps through it and goes back to it.
341
511
  const NAV = 'flow_invoice_nav';
@@ -435,16 +605,46 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
435
605
  h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
436
606
  }
437
607
 
438
- // The job an invoice bills. The case id stays on the record, not on screen.
439
- function jobCard(ui, job) {
608
+ // The Linked card: this invoice's link in each connected system (linksTo).
609
+ // A work system row names the job's order and opens it there; the
610
+ // accounting row gives the number and state there (from Flow's copy). A
611
+ // system with no link shows a dashed row. With no system on, the card holds
612
+ // the job's facts only.
613
+ function linkedCard(ui, invoice, accountingStatus = null) {
440
614
  const { h } = ui;
441
- if (!job) return null;
615
+ const job = invoice?.job ?? null;
616
+ const systems = invoice?.linksTo ?? [];
617
+ if (!job && !systems.length) return null;
618
+ const facts = job && (job.practitioner || job.shippedOn) ? h('dl', { class: 'fx-facts' },
619
+ job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
620
+ job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null) : null;
621
+ const row = (system, { tone = null, ref = null, state = null, url = null, none = null }) => h('div', { class: 'fx-linked__row' },
622
+ linkIcon(ui, system, { tone, title: none ?? `${system.name}${ref ? ` ${ref}` : ''}` }),
623
+ h('span', { class: 'fx-strong' }, system.name),
624
+ ref ? h('span', { class: 'fx-linked__ref' }, ref) : null,
625
+ state ? h('span', { class: 'fx-status', 'data-tone': tone ?? 'mute' }, state) : null,
626
+ none ? h('span', { class: 'fx-dim' }, none) : null,
627
+ url ? h('a', { class: 'fx-linked__open', href: url, target: '_blank', rel: 'noopener', title: `Open in ${system.name}` }, 'Open ↗') : null);
628
+ const rows = systems.map((system) => {
629
+ if (system.kind === 'work') {
630
+ return job?.sourceSystem === system.id
631
+ ? row(system, { tone: 'success', ref: job.caseRef ?? job.sourceJobRef, url: job.link?.url ?? null })
632
+ : row(system, { none: 'No job linked' });
633
+ }
634
+ const status = accountingStatus;
635
+ if (!status || (status.state === 'not_sent' && !status.finding)) {
636
+ return row(system, { none: invoice?.status === 'draft' ? 'Goes there when sent' : `Not in ${system.name} yet` });
637
+ }
638
+ const tone = status.finding ? 'danger' : LINK_TONE[status.state] ?? 'mute';
639
+ const state = status.finding ? 'Out of sync' : status.label.replace(` in ${system.name}`, '');
640
+ return row(system, { tone, ref: status.xero?.number ?? null, state, url: status.xero?.url ?? null });
641
+ });
642
+ const workRows = systems.some((s) => s.kind === 'work');
442
643
  return h('section', { class: 'fx-card fx-side-card' },
443
- h('h3', { class: 'fx-label' }, 'Job'),
444
- h('dl', { class: 'fx-facts' },
445
- job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
446
- job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null,
447
- h('dt', {}, 'From'), h('dd', {}, job.sourceSystem === 'seazona' ? 'Seazona' : job.sourceSystem)));
644
+ h('h3', { class: 'fx-label' }, 'Linked'),
645
+ !workRows && job ? h('p', { class: 'fx-dim' }, `Job from ${job.systemName ?? job.sourceSystem}`) : null,
646
+ h('div', { class: 'fx-linked' }, rows),
647
+ facts);
448
648
  }
449
649
 
450
650
  // A draft, or a new invoice before its first save.
@@ -796,7 +996,7 @@ function draftView(invoice, ui, context, redraw) {
796
996
  h('div', { class: 'fx-lines__foot' },
797
997
  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'),
798
998
  totalsHost))),
799
- h('aside', { class: 'fx-invoice__aside' }, statusCard, jobCard(ui, invoice?.job))),
999
+ h('aside', { class: 'fx-invoice__aside' }, statusCard, linkedCard(ui, invoice))),
800
1000
  ].map((part) => {
801
1001
  // Any edit on the page shows Save changes; undoing it hides it again.
802
1002
  for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
@@ -855,12 +1055,14 @@ function issuedView(invoice, ui, context, redraw) {
855
1055
  };
856
1056
  const organisationId = context.organisations?.[0]?.id;
857
1057
  const xeroHost = h('div');
1058
+ const linkedHost = h('div', {}, linkedCard(ui, invoice) ?? '');
858
1059
  let xeroStatus = null;
859
1060
  const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
860
1061
  const loadXero = async () => {
861
1062
  if (!context.xero) return;
862
1063
  xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
863
1064
  xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
1065
+ linkedHost.replaceChildren(linkedCard(ui, invoice, xeroStatus) ?? '');
864
1066
  if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
865
1067
  };
866
1068
  loadXero();
@@ -905,8 +1107,8 @@ function issuedView(invoice, ui, context, redraw) {
905
1107
  h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
906
1108
  h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
907
1109
  h('aside', { class: 'fx-invoice__aside' },
1110
+ linkedHost,
908
1111
  xeroHost,
909
- jobCard(ui, invoice.job),
910
1112
  invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
911
1113
  h('h3', { class: 'fx-label' }, 'History'),
912
1114
  h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
@@ -920,7 +1122,9 @@ function issuedView(invoice, ui, context, redraw) {
920
1122
  // never writes to Xero: a person fixes the difference where they choose.
921
1123
  function xeroCard(invoice, ui, context, status, reload) {
922
1124
  const { h, flow, toast } = ui;
923
- if (!status || (status.state === 'not_sent' && !status.finding)) return null;
1125
+ // The Linked card shows Xero's number and state; this card shows only a
1126
+ // difference to resolve.
1127
+ if (!status?.finding) return null;
924
1128
  const rows = status.differences.map((d) => h('tr', {},
925
1129
  h('td', {}, FIELD_LABEL[d.field] ?? d.field),
926
1130
  h('td', { class: 'fx-num' }, showField(d.field, d.flow)),
@@ -76,66 +76,84 @@ const capital = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
76
76
  const sentence = (text) => capital(String(text).trim().replace(/\.?$/, '.'));
77
77
 
78
78
  // One ledger reason (server/core/ledger.js) as a fix: a short label, what is
79
- // wrong, and what to do.
80
- function reasonFix(reason, acct) {
81
- const fix = (label, todo) => ({ label, text: sentence(reason), todo });
79
+ // wrong, what to do, and where to go to do it (`go`, see GO). A job's fix
80
+ // speaks of Flow's own invoice only. How that invoice stands in an
81
+ // accounting system is the invoice's business, on the invoice page.
82
+ function reasonFix(reason) {
83
+ const fix = (label, todo, go = null) => ({ label, text: sentence(reason), todo, go });
82
84
  if (reason === 'the accounting system was not checked') {
83
- return { label: `Not checked in ${acct}`, text: `Flow did not check ${acct} for an invoice for this job yet.`, todo: `Wait for the next check, a few minutes. If it stays, check ${acct} under Settings, Integrations.` };
85
+ return { label: 'Not checked yet', text: 'Flow did not check for an invoice for this job yet.', todo: 'Wait for the next check, a few minutes. If it stays, check Settings, Integrations.', go: 'integrations' };
84
86
  }
85
87
  if (reason === 'a line does not map to a price item') {
86
- return { label: 'No price code', text: 'A line has a code that is not in the price list.', todo: 'Add the code to the price list, or correct the code in the work system.' };
88
+ return { label: 'No price code', text: 'A line has a code that is not in the price list.', todo: 'Add the code to the price list, or correct the code in the work system.', go: 'price-list' };
87
89
  }
88
90
  if (reason === 'the job has no customer contact') {
89
- return { label: 'Client not linked', text: 'The client is not linked to a customer in Flow.', todo: 'Link the client to a customer in Flow.' };
91
+ return { label: 'Client not linked', text: 'The client is not linked to a customer in Flow.', todo: 'Link the client to a customer in Flow.', go: null };
90
92
  }
91
93
  if (reason === 'the work system report is out of date') {
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.' };
94
+ 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.', go: 'integrations' };
95
+ }
96
+ 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.', 'invoice');
97
+ if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.', 'work');
98
+ if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.', 'work');
99
+ // The matcher found invoices that may be this job's, but no exact link.
100
+ // A person makes an invoice or links one (jobs.js draws the two buttons).
101
+ if (/^the accounting evidence is ambiguous/.test(reason)) {
102
+ return { label: 'No invoice', text: 'This job has no invoice.', todo: null, go: 'choose' };
103
+ }
104
+ if (/^the accounting evidence is conflict/.test(reason)) {
105
+ return { label: 'Invoice link changed', text: 'A second invoice now answers for this job, or its invoice is gone.', todo: 'Check the invoice number on the job in the work system. Void an extra invoice.', go: 'work' };
106
+ }
107
+ if (/^invoice .* was (voided|deleted)$/.test(reason)) {
108
+ const voided = /voided$/.test(reason);
109
+ return { label: `Invoice ${voided ? 'voided' : 'deleted'}`, text: `The invoice for this job was ${voided ? 'voided' : 'deleted'}.`, todo: 'Make a new invoice, or close the job.', go: 'invoice' };
110
+ }
111
+ if (/^the accounting system reports /.test(reason)) {
112
+ return { label: 'May be invoiced', text: 'Flow found an invoice that may be for this job.', todo: 'Check the invoice number on the job in the work system.', go: 'work' };
93
113
  }
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
- 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
- if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.');
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.`);
98
- if (/^the accounting evidence is conflict/.test(reason)) return fix(`Two ${acct} invoices`, `Find the right invoice for this job in ${acct}. Void the other.`);
99
- if (/^invoice .* was (voided|deleted)$/.test(reason)) return fix(`Invoice ${/voided$/.test(reason) ? 'voided' : 'deleted'}`, 'Make a new invoice, or close the job.');
100
- if (/^the accounting system reports /.test(reason)) return fix(`Already in ${acct}`, `Check the invoice in ${acct}. Link it to this job if it is the right one.`);
101
114
  if (/^the job is held$/.test(reason)) return fix('On hold', 'Release the hold when the job can be invoiced.');
102
- if (/^the job is review$/.test(reason)) return fix('Needs review', 'Check the job and its invoice.');
115
+ if (/^the job is review$/.test(reason)) return fix('Needs review', 'Check the job and its invoice.', 'invoice');
103
116
  if (reason === 'held') return fix('On hold', 'Release the hold when the job can be invoiced.');
104
- return fix('Needs review', 'Check the job and its invoice.');
117
+ return fix('Needs review', 'Check the job and its invoice.', 'invoice');
105
118
  }
106
119
 
107
- // One draft problem (server/core/sending.js, and the accounting system's) as a fix.
108
- function draftFix(problem, acct) {
120
+ // One problem on the job's invoice (server/core/sending.js, and an accounting
121
+ // system's) as a job fix. Flow's own invoice problems keep their words. A
122
+ // problem an accounting system found stays on the invoice page; the job only
123
+ // says that its invoice needs a fix.
124
+ function draftFix(problem) {
109
125
  const code = problem.code ?? '';
110
- const fix = (label, todo) => ({ label, text: sentence(problem.message ?? label), todo });
126
+ const fix = (label, todo) => ({ label, text: sentence(problem.message ?? label), todo, go: 'invoice' });
111
127
  if (code === 'no_lines') return fix('No lines', 'Open the invoice and add the lines.');
112
128
  if (code === 'no_amount') return fix('Total is $0', 'Open the invoice and correct the amounts.');
113
129
  if (code === 'line_no_item') return fix('No price code', 'Open the invoice and choose a price item for each line.');
114
- if (code.endsWith('_item_missing')) return fix(`Item not in ${acct}`, `Add the item in ${acct}, then sync.`);
115
- if (code.endsWith('_contact_missing')) return fix(`Client not in ${acct}`, `Add the client in ${acct}, then sync.`);
116
- if (code.endsWith('_contact_ambiguous')) return fix(`Two ${acct} clients`, `Merge or rename the clients in ${acct}, then sync.`);
117
- if (code.includes('earlier_version')) return fix(`Old copy in ${acct}`, 'Open the invoice for the steps.');
118
- if (code.startsWith('send_') || code.endsWith('_push_failed') || code.endsWith('_outcome_unknown')) return fix('Not sent', 'Open the invoice and send it again.');
119
- return fix('Invoice problem', 'Open the invoice to correct it.');
130
+ if (code.startsWith('send_') || code.endsWith('_push_failed') || code.endsWith('_outcome_unknown')) {
131
+ return { label: 'Not sent', text: 'The invoice for this job was not sent.', todo: 'Open the invoice and send it again.', go: 'invoice' };
132
+ }
133
+ return { label: 'Invoice problem', text: 'The invoice for this job needs a fix before it can be sent.', todo: 'Open the invoice for the steps.', go: 'invoice' };
120
134
  }
121
135
 
122
- // What a person must fix on a job, in order: [{ label, text, todo }]. Empty
123
- // unless invoiceStage is Problem. `accountingName` names the accounting
124
- // system (for example Xero).
125
- export function fixes(o, { accountingName = null } = {}) {
136
+ // Where a fix's link goes: Flow's invoice, the job in the work system, a
137
+ // Flow settings page, or `choose`: Make invoice and Link invoice buttons
138
+ // (jobs.js draws them).
139
+ export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'choose'];
140
+
141
+ // What a person must fix on a job, in order: [{ label, text, todo, go }];
142
+ // `todo` is null when the buttons are the instruction.
143
+ // Empty unless invoiceStage is Problem.
144
+ export function fixes(o) {
126
145
  if (invoiceStage(o) !== 'problem') return [];
127
- const acct = accountingName ?? 'the accounting system';
128
146
  const found = [];
129
- if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r, acct)));
147
+ if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
130
148
  if (o.view === 'ready') {
131
149
  found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
132
- label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Invoicing, Rules.',
150
+ label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Invoice rules.', go: 'invoicing-settings',
133
151
  })));
134
152
  }
135
153
  if (o.invoiceStatus === 'void') {
136
- found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.' });
154
+ found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
137
155
  }
138
- found.push(...(o.invoiceProblems ?? []).map((p) => draftFix(p, acct)));
156
+ found.push(...(o.invoiceProblems ?? []).map((p) => draftFix(p)));
139
157
  return found;
140
158
  }
141
159
 
@@ -146,6 +164,14 @@ export function problemSummary(found) {
146
164
  return labels.length > 1 ? `${labels[0]} +${labels.length - 1}` : labels[0];
147
165
  }
148
166
 
167
+ // A system's own record id (a UUID) is not a case number a person reads.
168
+ // Search still finds the job by it (job-board.js matches).
169
+ const RECORD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
170
+ export function caseNumber(job) {
171
+ const ref = job.caseRef ?? job.sourceJobRef ?? null;
172
+ return ref && !RECORD_ID.test(ref) ? ref : null;
173
+ }
174
+
149
175
  // Does a job match the search text? Case, client, practitioner or invoice
150
176
  // number; the case is not shown on the board, but a person can find it.
151
177
  export function matches(job, q) {
@@ -5,10 +5,24 @@
5
5
  // (test/job-view.test.js). server/ui/jobs.js draws the page.
6
6
 
7
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' };
8
+ // 'none' is one plain list with no group heads. The board has a lane per
9
+ // group, so there it shows the status lanes.
10
+ export const GROUPS = { status: 'Status', stage: 'Invoice stage', client: 'Client', practitioner: 'Practitioner', none: 'None' };
11
+ export const SORTS = { board: 'Lab system order', due: 'Date', ordered: 'Requested', shipped: 'Shipped', client: 'Client', practitioner: 'Practitioner', total: 'Total' };
10
12
  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 });
13
+ // The list's columns, in order. Client is always the first column. One
14
+ // column holds one field: each date has its own column. A custom field is
15
+ // a column too ('field:<field id>'). Patient shows only when the
16
+ // installation keeps patient names; Assignee only when the lab has a
17
+ // person field.
18
+ export const COLUMNS = {
19
+ case: 'Case', practitioner: 'Practitioner', patient: 'Patient', stage: 'Stage', assignee: 'Assignee',
20
+ ordered: 'Requested', due: 'Due', shipped: 'Shipped', total: 'Total',
21
+ };
22
+ export const DEFAULT_COLUMNS = Object.freeze(['practitioner', 'patient', 'stage', 'assignee', 'due', 'total']);
23
+ export const DEFAULT_VIEW = Object.freeze({
24
+ layout: 'board', group: 'status', sort: 'board', dir: 'asc', hide: [], late: false, problems: false, mine: false, columns: DEFAULT_COLUMNS,
25
+ });
12
26
 
13
27
  // Group by one of the lab's custom fields (docs/CUSTOM_FIELDS.md):
14
28
  // 'field:<field id>'. Only dropdown, person and checkbox fields make lanes.
@@ -17,6 +31,7 @@ export const fieldGroup = (fieldId) => `field:${fieldId}`;
17
31
  export const fieldIdOf = (group) => (FIELD_GROUP.test(group ?? '') ? group.slice(6) : null);
18
32
 
19
33
  const MAX_HIDDEN = 100;
34
+ const MAX_COLUMNS = 40;
20
35
  const MAX_LABEL = 120;
21
36
 
22
37
  // A view as Flow stores it: only the known keys, each checked. Anything else
@@ -35,27 +50,35 @@ export function normaliseJobsView(input) {
35
50
  hide,
36
51
  late: value.late === true,
37
52
  problems: value.problems === true,
53
+ mine: value.mine === true,
54
+ columns: Array.isArray(value.columns)
55
+ ? [...new Set(value.columns.filter((c) => Object.hasOwn(COLUMNS, c) || FIELD_GROUP.test(c ?? '')))].slice(0, MAX_COLUMNS)
56
+ : [...DEFAULT_COLUMNS],
38
57
  };
39
58
  }
40
59
 
41
60
  // How many filters are on: shown on the Filter button.
42
61
  export function filterCount(view) {
43
- return view.hide.length + (view.late ? 1 : 0) + (view.problems ? 1 : 0);
62
+ return view.hide.length + (view.late ? 1 : 0) + (view.problems ? 1 : 0) + (view.mine ? 1 : 0);
44
63
  }
45
64
 
46
65
  // 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 }) {
66
+ // `problem` say if a job is late or has a problem; `mine` says if it is
67
+ // assigned to the person looking (jobs.js knows how).
68
+ export function filterJobs(jobs, view, { status, late, problem, mine = () => true }) {
49
69
  const hidden = new Set(view.hide);
50
70
  return jobs.filter((job) => !hidden.has(status(job))
51
71
  && (!view.late || late(job))
52
- && (!view.problems || problem(job)));
72
+ && (!view.problems || problem(job))
73
+ && (!view.mine || mine(job)));
53
74
  }
54
75
 
55
76
  const dayOf = (job) => job.dueOn ?? job.orderedOn ?? job.jobDate ?? null;
56
77
  const text = (value) => (value ?? '').toString().toLowerCase();
57
78
  const KEYS = {
58
79
  due: dayOf,
80
+ ordered: (job) => job.orderedOn ?? null,
81
+ shipped: (job) => job.shippedOn ?? null,
59
82
  client: (job) => text(job.clientName) || null,
60
83
  practitioner: (job) => text(job.practitioner) || null,
61
84
  total: (job) => job.totalCents ?? null,