@arcforgelabs/flow 2026.10.35 → 2026.10.37

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.
@@ -6,26 +6,27 @@
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
 
12
- const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
13
+ const STATUS_LABEL = { draft: 'Draft', issued: 'Finalised', void: 'Void' };
13
14
  const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
14
15
  // Drafts (to check), Problem and Ready are views (server/core/sending.js):
15
16
  // every draft is in one of them. A person marks a checked draft ready; a
16
17
  // saved change keeps it there. An issued invoice that failed to send is a
17
18
  // problem.
18
19
  // Ready first: it opens by default, as the drafts most often wait there to send.
19
- const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Issued'], ['void', 'Void']];
20
+ const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Finalised'], ['void', 'Void']];
20
21
  const VIEW_LABEL = { problem: 'Problem', ready: 'Ready', sending: 'Sending', to_check: 'Draft' };
21
22
  const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_check: 'info' };
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);
@@ -46,6 +47,35 @@ export function xeroTag(ui, status) {
46
47
  : h('span', { class: 'fx-status', 'data-tone': XERO_TONE[status.state] ?? 'mute' }, status.label);
47
48
  }
48
49
 
50
+ // The Linked column: one icon for each connected system (linksTo), with a
51
+ // dot for the state of this invoice's link there. An icon with a link opens
52
+ // the record in that system. Accounting icons show on numbered invoices only.
53
+ const LINK_TONE = {
54
+ not_sent: 'warn', sending: 'warn', not_copied: 'warn', draft: 'success',
55
+ approved: 'success', part_paid: 'success', paid: 'success', voided: 'mute', deleted: 'mute',
56
+ };
57
+ export function linkIcon(ui, system, { tone = null, title, url = null } = {}) {
58
+ const { h } = ui;
59
+ const kids = [h('img', { src: `/assets/ui/logos/${system.id}.svg`, alt: '' }), tone ? h('i', { class: 'fx-link__dot', 'data-tone': tone }) : null];
60
+ const props = { class: `fx-link${tone ? '' : ' fx-link--none'}`, title, 'aria-label': title };
61
+ return url
62
+ ? h('a', { ...props, href: url, target: '_blank', rel: 'noopener', onclick: (event) => event.stopPropagation() }, ...kids)
63
+ : h('span', props, ...kids);
64
+ }
65
+
66
+ function workIcon(ui, system, job) {
67
+ if (job?.sourceSystem !== system.id) return linkIcon(ui, system, { title: `Not linked to ${system.name}` });
68
+ const ref = job.caseRef ?? job.sourceJobRef;
69
+ return linkIcon(ui, system, { tone: 'success', title: `${system.name} ${ref}${job.link?.url ? `. Open in ${system.name}.` : ''}`, url: job.link?.url ?? null });
70
+ }
71
+
72
+ export function accountingIcon(ui, system, status) {
73
+ const tone = status.finding ? 'danger' : LINK_TONE[status.state] ?? 'mute';
74
+ const number = status.xero?.number ? ` ${status.xero.number}` : '';
75
+ const title = `${system.name}${number}: ${status.finding ? status.message : status.label}${status.xero?.url ? ` Open in ${system.name}.` : ''}`;
76
+ return linkIcon(ui, system, { tone, title, url: status.xero?.url ?? null });
77
+ }
78
+
49
79
  const showField = (field, value) => (['subtotal', 'tax', 'total'].includes(field) && Number.isFinite(value) ? formatCents(value) : String(value ?? '—'));
50
80
 
51
81
  // Xero's status for some invoices, or an empty map while Xero is off.
@@ -91,18 +121,28 @@ export async function renderInvoiceList(main, ui) {
91
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 });
92
122
  const bar = h('div', { class: 'fx-sendbar', hidden: true });
93
123
  const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
124
+ const bulk = bulkBar(ui);
94
125
  const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
95
126
  let body = null;
96
127
  const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
97
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' });
98
133
  main.append(
99
134
  pageHeader('Invoices', {
100
135
  actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
101
136
  }),
102
137
  alerts.element,
103
- h('div', { class: 'fx-toolbar' }, tabs, search),
104
- bar,
105
- 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
+ }
106
146
 
107
147
  let timer;
108
148
  search.addEventListener('input', () => {
@@ -138,33 +178,180 @@ export async function renderInvoiceList(main, ui) {
138
178
  const ticked = [...state.ticked];
139
179
  const parts = [];
140
180
  if (state.status === 'to_check') {
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.'));
142
- if (ticked.length) {
143
- parts.push(action(`Move ${ticked.length} to Ready`, 'mark-ready', () => ({ invoiceIds: ticked }), (res) => (res.refused.length
144
- ? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
145
- : [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
146
- }
181
+ parts.push(h('p', { class: 'fx-dim' }, 'Check each draft, tick it, then press Ready.'));
147
182
  } else if (state.status === 'ready') {
148
183
  parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
149
184
  const buttons = [];
150
- if (ticked.length) {
151
- buttons.push(action(`Move ${ticked.length} to Drafts`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
152
- (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
153
- }
154
185
  if (context.canIssue && body.counts.ready > 0) {
155
186
  buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Finalise all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
156
- (res) => [`${res.issued} issued.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
187
+ (res) => [`${res.issued} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
157
188
  }
158
189
  if (buttons.length) parts.push(h('div', { class: 'fx-sendbar__actions' }, buttons));
159
190
  } else if (state.status === 'problem') {
160
191
  const failed = body.invoices.filter((i) => i.status === 'issued').length;
161
192
  if (context.canIssue && to && failed > 0) {
162
- parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, 'issued invoice', 'issued invoices')} did not reach ${to}. Fix the cause, then send again.`));
193
+ parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, 'finalised invoice', 'finalised invoices')} did not reach ${to}. Fix the cause, then send again.`));
163
194
  parts.push(action('Send again', 'send-again', () => ({}), (res) => [`${res.queued} on the way to ${to} again.`, 'success']));
164
195
  }
165
196
  }
166
197
  bar.replaceChildren(...parts);
167
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();
168
355
  }
169
356
 
170
357
  // A tab switch shows at once: the tab is selected, the last list seen for
@@ -228,12 +415,15 @@ export async function renderInvoiceList(main, ui) {
228
415
  saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
229
416
  const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
230
417
  const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
231
- const ticks = TICKS[state.status];
232
418
  const showProblem = state.status === 'problem';
233
419
  const created = ['to_check', 'problem', 'ready'].includes(state.status);
234
420
  // An invoice gets its number when it is sent or issued, so only the
235
- // Issued and Void tabs show a Number column; the others open on Client.
421
+ // Finalised and Void tabs show a Number column; the others open on Client.
236
422
  const numbered = !created;
423
+ // With an integration on, every tab has a Linked column (linkIcon).
424
+ const systems = body.linksTo ?? [];
425
+ const linked = systems.length > 0;
426
+ const accountingSystem = systems.find((s) => s.kind === 'accounting') ?? null;
237
427
  const boxes = [];
238
428
  const tickBox = (invoice) => {
239
429
  const box = h('input', {
@@ -247,6 +437,7 @@ export async function renderInvoiceList(main, ui) {
247
437
  };
248
438
  const all = h('input', {
249
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 } : {}),
250
441
  onchange: () => {
251
442
  for (const invoice of body.invoices) { if (all.checked) state.ticked.add(invoice.id); else state.ticked.delete(invoice.id); }
252
443
  for (const box of boxes) box.checked = all.checked;
@@ -256,15 +447,16 @@ export async function renderInvoiceList(main, ui) {
256
447
  const statusCells = new Map();
257
448
  host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
258
449
  h('thead', {}, h('tr', {},
259
- ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
450
+ h('th', { class: 'fx-tick-cell' }, all),
260
451
  numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
261
- h('th', {}, created ? 'Created' : 'Issued'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'))),
452
+ h('th', {}, created ? 'Created' : 'Finalised'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'),
453
+ linked ? h('th', {}, 'Linked') : null)),
262
454
  h('tbody', {}, body.invoices.map((invoice) => {
263
455
  const href = `/invoices/${invoice.id}`;
264
456
  const first = invoice.problems?.[0];
265
457
  const view = invoice.status === 'void' ? null : invoice.view;
266
458
  return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
267
- 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)),
268
460
  numbered ? h('td', {}, link(href, invoice.number ?? 'Draft')) : null,
269
461
  h('td', {}, numbered ? (invoice.contactName ?? '—') : link(href, invoice.contactName ?? 'No client')),
270
462
  showProblem
@@ -276,17 +468,25 @@ export async function renderInvoiceList(main, ui) {
276
468
  const cell = h('td', { class: 'fx-status-cell' }, view
277
469
  ? h('span', { class: 'fx-status', 'data-tone': VIEW_TONE[view] }, VIEW_LABEL[view])
278
470
  : h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status]));
471
+ if (!linked) statusCells.set(invoice.id, cell);
472
+ return cell;
473
+ })(),
474
+ linked ? (() => {
475
+ const cell = h('td', { class: 'fx-links' }, systems.filter((s) => s.kind === 'work').map((s) => workIcon(ui, s, invoice.job)));
279
476
  statusCells.set(invoice.id, cell);
280
477
  return cell;
281
- })());
478
+ })() : null);
282
479
  }))));
283
- // Issued and void invoices also show Xero's status, from Flow's copy.
284
- if (['issued', 'void'].includes(state.status)) {
480
+ // Finalised and void invoices also show Xero's status, from Flow's copy:
481
+ // in the Linked column every one gets an icon, so one not in Xero
482
+ // stands out.
483
+ if (numbered) {
285
484
  const shownBody = body;
286
485
  const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, body.invoices.map((i) => i.id));
287
486
  if (shownBody !== body) return;
288
487
  for (const [invoiceId, status] of statuses) {
289
- if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
488
+ if (linked) { if (accountingSystem) statusCells.get(invoiceId)?.append(accountingIcon(ui, accountingSystem, status)); }
489
+ else if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
290
490
  }
291
491
  }
292
492
  }
@@ -294,6 +494,18 @@ export async function renderInvoiceList(main, ui) {
294
494
  if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
295
495
  }
296
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
+
297
509
  // The list an invoice was opened from: its tab, search, invoice ids in order,
298
510
  // and scroll. The invoice page's bar steps through it and goes back to it.
299
511
  const NAV = 'flow_invoice_nav';
@@ -307,7 +519,7 @@ function saveNav(nav) {
307
519
 
308
520
  // The bar above one invoice: Back to the list as it was, and the previous and
309
521
  // 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
522
+ // search, a link) steps through its own tab: Drafts, Ready, Problem, Finalised
311
523
  // or Void, read once here.
312
524
  function navBar(ui, invoice) {
313
525
  const { h, go, flow } = ui;
@@ -393,16 +605,46 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
393
605
  h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
394
606
  }
395
607
 
396
- // The job an invoice bills. The case id stays on the record, not on screen.
397
- 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) {
398
614
  const { h } = ui;
399
- 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');
400
643
  return h('section', { class: 'fx-card fx-side-card' },
401
- h('h3', { class: 'fx-label' }, 'Job'),
402
- h('dl', { class: 'fx-facts' },
403
- job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
404
- job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null,
405
- 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);
406
648
  }
407
649
 
408
650
  // A draft, or a new invoice before its first save.
@@ -754,7 +996,7 @@ function draftView(invoice, ui, context, redraw) {
754
996
  h('div', { class: 'fx-lines__foot' },
755
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'),
756
998
  totalsHost))),
757
- h('aside', { class: 'fx-invoice__aside' }, statusCard, jobCard(ui, invoice?.job))),
999
+ h('aside', { class: 'fx-invoice__aside' }, statusCard, linkedCard(ui, invoice))),
758
1000
  ].map((part) => {
759
1001
  // Any edit on the page shows Save changes; undoing it hides it again.
760
1002
  for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
@@ -813,12 +1055,14 @@ function issuedView(invoice, ui, context, redraw) {
813
1055
  };
814
1056
  const organisationId = context.organisations?.[0]?.id;
815
1057
  const xeroHost = h('div');
1058
+ const linkedHost = h('div', {}, linkedCard(ui, invoice) ?? '');
816
1059
  let xeroStatus = null;
817
1060
  const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
818
1061
  const loadXero = async () => {
819
1062
  if (!context.xero) return;
820
1063
  xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
821
1064
  xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
1065
+ linkedHost.replaceChildren(linkedCard(ui, invoice, xeroStatus) ?? '');
822
1066
  if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
823
1067
  };
824
1068
  loadXero();
@@ -826,7 +1070,7 @@ function issuedView(invoice, ui, context, redraw) {
826
1070
  const voidBody = () => {
827
1071
  const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
828
1072
  return h('div', {},
829
- h('p', {}, 'The number stays used. A void invoice cannot be issued again.'),
1073
+ h('p', {}, 'The number stays used. A void invoice cannot be finalised again.'),
830
1074
  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);
831
1075
  };
832
1076
  const actions = invoice.status === 'issued' && context.canIssue ? [
@@ -843,7 +1087,7 @@ function issuedView(invoice, ui, context, redraw) {
843
1087
  h('div', { class: 'fx-row' },
844
1088
  h('h1', { class: 'fx-title' }, invoice.number),
845
1089
  h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])),
846
- h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · issued ${showDate(invoice.issueDate)}`)),
1090
+ h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · finalised ${showDate(invoice.issueDate)}`)),
847
1091
  h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
848
1092
  h('div', { class: 'fx-invoice__grid' },
849
1093
  h('div', { class: 'fx-invoice__main' },
@@ -851,7 +1095,7 @@ function issuedView(invoice, ui, context, redraw) {
851
1095
  h('dl', { class: 'fx-doc__meta' },
852
1096
  h('div', {}, h('dt', { class: 'fx-label' }, 'Bill to'), h('dd', {}, invoice.contact?.name ?? '—', invoice.contact?.email ? h('span', { class: 'fx-dim' }, invoice.contact.email) : null)),
853
1097
  h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
854
- h('div', {}, h('dt', { class: 'fx-label' }, 'Issued'), h('dd', {}, showDate(invoice.issueDate))),
1098
+ h('div', {}, h('dt', { class: 'fx-label' }, 'Finalised'), h('dd', {}, showDate(invoice.issueDate))),
855
1099
  h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
856
1100
  h('table', { class: 'fx-lines__table fx-lines__table--read' },
857
1101
  h('thead', {}, h('tr', {}, h('th', {}, 'Description'), h('th', { class: 'fx-num' }, 'Qty'), h('th', { class: 'fx-num' }, 'Unit price'), h('th', {}, 'Tax'), h('th', { class: 'fx-num' }, 'Amount'))),
@@ -863,12 +1107,12 @@ function issuedView(invoice, ui, context, redraw) {
863
1107
  h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
864
1108
  h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
865
1109
  h('aside', { class: 'fx-invoice__aside' },
1110
+ linkedHost,
866
1111
  xeroHost,
867
- jobCard(ui, invoice.job),
868
1112
  invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
869
1113
  h('h3', { class: 'fx-label' }, 'History'),
870
1114
  h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
871
- h('span', { class: 'fx-strong' }, v.version === 1 ? 'Issued' : `Reissued (version ${v.version})`),
1115
+ h('span', { class: 'fx-strong' }, v.version === 1 ? 'Finalised' : `Revised (version ${v.version})`),
872
1116
  h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
873
1117
  ];
874
1118
  }
@@ -878,7 +1122,9 @@ function issuedView(invoice, ui, context, redraw) {
878
1122
  // never writes to Xero: a person fixes the difference where they choose.
879
1123
  function xeroCard(invoice, ui, context, status, reload) {
880
1124
  const { h, flow, toast } = ui;
881
- 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;
882
1128
  const rows = status.differences.map((d) => h('tr', {},
883
1129
  h('td', {}, FIELD_LABEL[d.field] ?? d.field),
884
1130
  h('td', { class: 'fx-num' }, showField(d.field, d.flow)),