@arcforgelabs/flow 2026.10.25 → 2026.10.26

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 +2 -1
  4. package/server/api/invoicing.js +375 -0
  5. package/server/app.js +11 -0
  6. package/server/auth/people.js +26 -0
  7. package/server/auth/routes.js +13 -0
  8. package/server/core/invoices.js +7 -0
  9. package/server/core/ledger.js +6 -3
  10. package/server/core/links.js +3 -3
  11. package/server/core/pipeline.js +488 -0
  12. package/server/db/migrations/0008_people_appearance.sql +1 -0
  13. package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
  14. package/server/db/migrations/meta/0008_snapshot.json +2479 -0
  15. package/server/db/migrations/meta/0009_snapshot.json +2809 -0
  16. package/server/db/migrations/meta/_journal.json +14 -0
  17. package/server/db/schema.js +71 -2
  18. package/server/index.js +9 -0
  19. package/server/integrations/port.js +3 -0
  20. package/server/integrations/seazona/feed.js +11 -0
  21. package/server/integrations/seazona/index.js +7 -1
  22. package/server/integrations/seazona/ledger.js +2 -0
  23. package/server/integrations/xero/import.js +22 -0
  24. package/server/integrations/xero/index.js +10 -2
  25. package/server/integrations/xero/lookup.js +94 -0
  26. package/server/theme/color-contrast.js +39 -2
  27. package/server/theme/contrast-audit.js +63 -0
  28. package/server/theme/themes.js +10 -2
  29. package/server/ui/app-page.js +5 -10
  30. package/server/ui/app.js +351 -105
  31. package/server/ui/appearance.js +3 -2
  32. package/server/ui/clerk-appearance.js +18 -2
  33. package/server/ui/flow.css +246 -22
  34. package/server/ui/invoicing.js +451 -0
  35. package/server/ui/jobs.js +153 -0
  36. package/server/ui/logos/seazona.svg +1 -0
  37. package/server/ui/logos/xero.svg +1 -0
  38. package/server/ui/routes.js +5 -1
@@ -0,0 +1,451 @@
1
+ // Invoices: the list (/invoices) and one invoice (/invoices/new,
2
+ // /invoices/<id>). A draft is edited in place; issuing gives it its number
3
+ // and keeps the document exactly as sent. Money is whole cents on the wire
4
+ // and dollars on screen. The server works out every total; the totals here
5
+ // are a preview with the same rule (GST 10% per line, rounded half away from
6
+ // zero, server/core/money.js). Data comes from /api/invoicing
7
+ // (server/api/invoicing.js). app.js passes in its shared pieces as `ui`.
8
+
9
+ const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
10
+ const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
11
+ const TABS = [['draft', 'Drafts'], ['issued', 'Issued'], ['void', 'Void']];
12
+
13
+ const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
14
+ export const formatCents = (cents) => money.format((cents ?? 0) / 100);
15
+
16
+ function gstCents(lineAmountCents) {
17
+ const sign = Math.sign(lineAmountCents);
18
+ const abs = Math.abs(lineAmountCents) * 1000;
19
+ return sign * Math.floor((abs * 2 + 10_000) / 20_000);
20
+ }
21
+
22
+ // Dollars typed by a person, to whole cents; null when it is not a number.
23
+ function toCents(text) {
24
+ const clean = String(text ?? '').replace(/[$,\s]/g, '');
25
+ if (!/^-?\d+(\.\d{0,2})?$/.test(clean)) return null;
26
+ return Math.round(Number(clean) * 100);
27
+ }
28
+
29
+ const fromCents = (cents) => (cents == null ? '' : (cents / 100).toFixed(2));
30
+
31
+ function localDate(date = new Date()) {
32
+ const pad = (n) => String(n).padStart(2, '0');
33
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
34
+ }
35
+
36
+ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
37
+
38
+ // ── The list ───────────────────────────────────────────────────────────────
39
+
40
+ export async function renderInvoiceList(main, ui) {
41
+ const { h, flow, pageHeader, notice, go } = ui;
42
+ const state = { status: sessionStorage.getItem('flow_invoice_tab') || 'draft', q: '' };
43
+ const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
44
+ const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice' });
45
+ const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
46
+ main.append(
47
+ pageHeader('Invoices', {
48
+ actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
49
+ }),
50
+ h('div', { class: 'fx-toolbar' }, tabs, search),
51
+ host);
52
+
53
+ let timer;
54
+ search.addEventListener('input', () => {
55
+ clearTimeout(timer);
56
+ timer = setTimeout(() => { state.q = search.value.trim(); load(); }, 200);
57
+ });
58
+
59
+ async function load() {
60
+ let body;
61
+ try {
62
+ const query = new URLSearchParams({ status: state.status, ...(state.q ? { q: state.q } : {}) });
63
+ body = await flow(`/api/invoicing/invoices?${query}`);
64
+ } catch (error) {
65
+ host.replaceChildren(notice(error.message, 'danger'));
66
+ return;
67
+ }
68
+ tabs.replaceChildren(...TABS.map(([id, label]) => h('button', {
69
+ type: 'button',
70
+ role: 'tab',
71
+ 'aria-selected': String(state.status === id),
72
+ onclick: () => { state.status = id; sessionStorage.setItem('flow_invoice_tab', id); load(); },
73
+ }, label, h('span', { class: 'fx-tabs__count' }, String(body.counts?.[id] ?? 0)))));
74
+ if (!body.invoices.length) {
75
+ host.replaceChildren(h('p', { class: 'fx-empty' }, state.q
76
+ ? 'No invoice matches that search.'
77
+ : state.status === 'draft' ? 'No drafts. Press New invoice to start one.' : `No ${STATUS_LABEL[state.status].toLowerCase()} invoices yet.`));
78
+ return;
79
+ }
80
+ host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
81
+ h('thead', {}, h('tr', {},
82
+ h('th', {}, 'Number'), h('th', {}, 'Client'), h('th', {}, 'Reference'),
83
+ h('th', {}, state.status === 'draft' ? 'Created' : 'Issued'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'))),
84
+ h('tbody', {}, body.invoices.map((invoice) => {
85
+ const href = `/invoices/${invoice.id}`;
86
+ return h('tr', { tabindex: '0', onclick: () => go(href), onkeydown: (event) => { if (event.key === 'Enter') go(href); } },
87
+ h('td', {}, h('a', { class: 'fx-strong', href, onclick: go }, invoice.number ?? 'Draft')),
88
+ h('td', {}, invoice.contactName ?? '—'),
89
+ h('td', { class: 'fx-dim' }, invoice.job?.caseRef ?? invoice.reference ?? '—'),
90
+ h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
91
+ h('td', { class: 'fx-num fx-strong' }, formatCents(invoice.totalCents)),
92
+ h('td', {}, h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])));
93
+ }))));
94
+ }
95
+ await load();
96
+ }
97
+
98
+ // ── One invoice ────────────────────────────────────────────────────────────
99
+
100
+ export async function renderInvoice(main, ui, id) {
101
+ const { h, flow, notice } = ui;
102
+ const host = h('div', { class: 'fx-invoice' }, h('div', { class: 'fx-skeleton' }));
103
+ main.append(host);
104
+ let context;
105
+ let invoice = null;
106
+ try {
107
+ [context, invoice] = await Promise.all([
108
+ flow('/api/invoicing/context'),
109
+ id === 'new' ? null : flow(`/api/invoicing/invoices/${id}`).then((body) => body.invoice),
110
+ ]);
111
+ } catch (error) {
112
+ host.replaceChildren(notice(error.status === 404 ? 'No invoice here. It may have been removed.' : error.message, 'danger'));
113
+ return;
114
+ }
115
+ // Send to Xero shows only when Xero is on and connected (404 while it is off).
116
+ const organisationId = context.organisations[0]?.id;
117
+ const xero = organisationId ? await flow(`/api/xero/status?organisationId=${organisationId}`).catch(() => null) : null;
118
+ context.xero = Boolean(xero?.connected);
119
+ const draw = (current) => {
120
+ host.replaceChildren(...(current?.status && current.status !== 'draft'
121
+ ? issuedView(current, ui, context, draw)
122
+ : draftView(current, ui, context, draw)));
123
+ };
124
+ draw(invoice);
125
+ }
126
+
127
+ function crumbs(ui, label) {
128
+ const { h, go } = ui;
129
+ return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
130
+ h('a', { href: '/invoices', onclick: go }, 'Invoices'), h('span', { 'aria-hidden': 'true' }, '/'), h('span', {}, label));
131
+ }
132
+
133
+ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
134
+ const { h } = ui;
135
+ return h('dl', { class: 'fx-totals' },
136
+ h('dt', {}, 'Subtotal'), h('dd', {}, formatCents(subtotalCents)),
137
+ h('dt', {}, 'GST'), h('dd', {}, formatCents(taxCents)),
138
+ h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
139
+ }
140
+
141
+ function jobCard(ui, job) {
142
+ const { h } = ui;
143
+ if (!job) return null;
144
+ return h('section', { class: 'fx-card fx-side-card' },
145
+ h('h3', { class: 'fx-label' }, 'Job'),
146
+ h('dl', { class: 'fx-facts' },
147
+ h('dt', {}, 'Case'), h('dd', {}, job.caseRef ?? job.sourceJobRef),
148
+ job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
149
+ job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null,
150
+ h('dt', {}, 'From'), h('dd', {}, job.sourceSystem === 'seazona' ? 'Seazona' : job.sourceSystem)));
151
+ }
152
+
153
+ // A draft, or a new invoice before its first save.
154
+ function draftView(invoice, ui, context, redraw) {
155
+ const { h, flow, toast, go } = ui;
156
+ const isNew = !invoice;
157
+ const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
158
+ if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
159
+ let contact = invoice?.contact ?? null;
160
+
161
+ // Client: type to find a customer.
162
+ const clientInput = h('input', { class: 'fx-input', type: 'text', autocomplete: 'off', placeholder: 'Type a client name', value: contact?.name ?? '', 'aria-label': 'Client' });
163
+ const clientList = h('div', { class: 'fx-suggest', role: 'listbox', hidden: true });
164
+ let clientTimer;
165
+ clientInput.addEventListener('input', () => {
166
+ contact = null;
167
+ clearTimeout(clientTimer);
168
+ clientTimer = setTimeout(async () => {
169
+ const q = clientInput.value.trim();
170
+ if (!q) { clientList.hidden = true; return; }
171
+ try {
172
+ const { contacts } = await flow(`/api/invoicing/contacts?${new URLSearchParams({ q, limit: '8' })}`);
173
+ clientList.replaceChildren(...(contacts.length ? contacts.map((c) => h('button', {
174
+ type: 'button',
175
+ role: 'option',
176
+ onmousedown: (event) => {
177
+ event.preventDefault();
178
+ contact = c;
179
+ clientInput.value = c.name;
180
+ clientList.hidden = true;
181
+ },
182
+ }, c.name, c.email ? h('span', { class: 'fx-dim' }, c.email) : null)) : [h('p', { class: 'fx-dim fx-suggest__none' }, 'No client by that name.')]));
183
+ clientList.hidden = false;
184
+ } catch (error) {
185
+ toast(error.message, 'danger');
186
+ }
187
+ }, 180);
188
+ });
189
+ clientInput.addEventListener('blur', () => { setTimeout(() => { clientList.hidden = true; }, 120); });
190
+
191
+ const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: 'Case or order number' });
192
+ const dueDate = h('input', { class: 'fx-input', type: 'date', value: invoice?.dueDate ?? '' });
193
+
194
+ // Lines.
195
+ let items = null;
196
+ const itemsReady = flow('/api/invoicing/price-items').then((body) => { items = body.priceItems ?? body.items ?? []; return items; }).catch(() => { items = []; return items; });
197
+ const tbody = h('tbody');
198
+ const datalist = h('datalist', { id: 'fx-price-items' });
199
+ const totalsHost = h('div');
200
+ const preview = () => {
201
+ let subtotalCents = 0;
202
+ let taxCents = 0;
203
+ for (const line of lines) {
204
+ if (line.unitAmountCents == null || !(line.quantity > 0)) continue;
205
+ const amount = line.quantity * line.unitAmountCents;
206
+ subtotalCents += amount;
207
+ taxCents += line.taxCode === 'GST' ? gstCents(amount) : 0;
208
+ }
209
+ totalsHost.replaceChildren(totalsBox(ui, { subtotalCents, taxCents, totalCents: subtotalCents + taxCents }));
210
+ };
211
+
212
+ const drawLines = () => {
213
+ tbody.replaceChildren(...lines.map((line, index) => {
214
+ const amount = h('td', { class: 'fx-num fx-strong' });
215
+ const showAmount = () => {
216
+ amount.textContent = line.unitAmountCents == null || !(line.quantity > 0) ? '—' : formatCents(line.quantity * line.unitAmountCents);
217
+ };
218
+ showAmount();
219
+ const item = h('input', {
220
+ class: 'fx-input', type: 'text', list: 'fx-price-items', placeholder: 'Code or name', autocomplete: 'off',
221
+ value: line.itemCode ?? '', 'aria-label': `Item, line ${index + 1}`,
222
+ });
223
+ const description = h('input', { class: 'fx-input', type: 'text', value: line.description ?? '', 'aria-label': `Description, line ${index + 1}` });
224
+ const quantity = h('input', { class: 'fx-input fx-num', type: 'number', min: '1', step: '1', value: String(line.quantity ?? 1), 'aria-label': `Quantity, line ${index + 1}` });
225
+ const price = h('input', { class: 'fx-input fx-num', type: 'text', inputmode: 'decimal', value: fromCents(line.unitAmountCents), placeholder: '0.00', 'aria-label': `Unit price, line ${index + 1}` });
226
+ const gst = h('select', { class: 'fx-select', 'aria-label': `GST, line ${index + 1}` },
227
+ h('option', { value: 'GST', selected: line.taxCode !== 'GST_FREE' }, 'GST'),
228
+ h('option', { value: 'GST_FREE', selected: line.taxCode === 'GST_FREE' }, 'GST free'));
229
+ item.addEventListener('change', async () => {
230
+ const list = items ?? await itemsReady;
231
+ const typed = item.value.trim().toLowerCase();
232
+ const match = list.find((i) => i.code.toLowerCase() === typed || `${i.code} · ${i.name}`.toLowerCase() === typed || i.name.toLowerCase() === typed);
233
+ if (!match) { line.priceItemId = null; line.itemCode = null; return; }
234
+ Object.assign(line, { priceItemId: match.id, itemCode: match.code, description: match.name, unitAmountCents: match.unitAmountCents, taxCode: match.taxCode });
235
+ item.value = match.code;
236
+ description.value = match.name;
237
+ price.value = fromCents(match.unitAmountCents);
238
+ gst.value = match.taxCode;
239
+ showAmount();
240
+ preview();
241
+ });
242
+ description.addEventListener('input', () => { line.description = description.value; });
243
+ quantity.addEventListener('input', () => { line.quantity = Number.parseInt(quantity.value, 10); showAmount(); preview(); });
244
+ price.addEventListener('input', () => { line.unitAmountCents = toCents(price.value); showAmount(); preview(); });
245
+ gst.addEventListener('change', () => { line.taxCode = gst.value; preview(); });
246
+ const remove = h('button', {
247
+ class: 'fx-btn fx-btn--ghost fx-btn--sm fx-btn--icon', type: 'button', title: 'Remove line', 'aria-label': `Remove line ${index + 1}`,
248
+ onclick: () => { lines.splice(index, 1); if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); preview(); },
249
+ }, '×');
250
+ return h('tr', {},
251
+ h('td', { class: 'fx-lines__item' }, item),
252
+ h('td', {}, description),
253
+ h('td', { class: 'fx-lines__qty' }, quantity),
254
+ h('td', { class: 'fx-lines__price' }, price),
255
+ h('td', { class: 'fx-lines__gst' }, gst),
256
+ amount,
257
+ h('td', {}, remove));
258
+ }));
259
+ };
260
+ drawLines();
261
+ preview();
262
+ itemsReady.then((list) => {
263
+ // A saved line knows its price item by id; show the item's code.
264
+ const codes = new Map(list.map((i) => [i.id, i.code]));
265
+ let named = false;
266
+ for (const line of lines) {
267
+ if (line.priceItemId && !line.itemCode && codes.has(line.priceItemId)) { line.itemCode = codes.get(line.priceItemId); named = true; }
268
+ }
269
+ if (named) drawLines();
270
+ datalist.replaceChildren(...list.map((i) => h('option', { value: i.code }, `${i.name} · ${formatCents(i.unitAmountCents)}`)));
271
+ });
272
+
273
+ const body = () => ({
274
+ contactId: contact?.id,
275
+ reference: reference.value.trim() || null,
276
+ dueDate: dueDate.value || null,
277
+ lines: lines
278
+ .filter((line) => line.description?.trim() || line.priceItemId || line.unitAmountCents != null)
279
+ .map(({ priceItemId, description, quantity, unitAmountCents, taxCode }) => ({
280
+ ...(priceItemId ? { priceItemId } : {}),
281
+ description: description?.trim() || undefined,
282
+ quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1,
283
+ ...(unitAmountCents == null ? {} : { unitAmountCents }),
284
+ taxCode,
285
+ })),
286
+ });
287
+
288
+ const check = () => {
289
+ if (!contact) return 'Choose a client from the list.';
290
+ if (lines.some((line) => (line.description?.trim() || line.priceItemId) && line.unitAmountCents == null)) return 'Each line needs a price.';
291
+ return null;
292
+ };
293
+
294
+ const save = async () => {
295
+ const problem = check();
296
+ if (problem) { toast(problem, 'danger'); return null; }
297
+ try {
298
+ const res = isNew
299
+ ? await flow('/api/invoicing/invoices', { method: 'POST', body: JSON.stringify(body()) })
300
+ : await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'PATCH', body: JSON.stringify(body()) });
301
+ return res.invoice;
302
+ } catch (error) {
303
+ toast(error.message, 'danger');
304
+ return null;
305
+ }
306
+ };
307
+
308
+ const saveButton = h('button', {
309
+ class: 'fx-btn', type: 'button',
310
+ onclick: async () => {
311
+ const saved = await save();
312
+ if (!saved) return;
313
+ toast('Draft saved.', 'success');
314
+ if (isNew) go(`/invoices/${saved.id}`, { replace: true });
315
+ else redraw(saved);
316
+ },
317
+ }, 'Save draft');
318
+ const issueButton = context.canIssue ? h('button', {
319
+ class: 'fx-btn fx-btn--primary', type: 'button',
320
+ onclick: async () => {
321
+ const saved = await save();
322
+ if (!saved) return;
323
+ const ok = await ui.dialog({
324
+ title: 'Issue this invoice?',
325
+ body: h('p', {}, `It gets the next number (${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}) and is kept exactly as issued. To change it later, revise it.`),
326
+ confirm: 'Issue',
327
+ });
328
+ if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
329
+ try {
330
+ const { invoice: issued } = await flow(`/api/invoicing/invoices/${saved.id}/issue`, { method: 'POST', body: JSON.stringify({ issueDate: localDate() }) });
331
+ toast(`${issued.number} issued.`, 'success');
332
+ if (isNew) go(`/invoices/${issued.id}`, { replace: true });
333
+ else redraw(issued);
334
+ } catch (error) {
335
+ toast(error.message, 'danger');
336
+ }
337
+ },
338
+ }, 'Issue invoice') : null;
339
+
340
+ return [
341
+ crumbs(ui, isNew ? 'New invoice' : 'Draft'),
342
+ h('header', { class: 'fx-ph' },
343
+ h('div', { class: 'fx-ph__text' },
344
+ h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : 'Draft invoice'),
345
+ h('p', { class: 'fx-ph__sub' }, 'The number is given when you issue it.')),
346
+ h('div', { class: 'fx-ph__actions' }, saveButton, issueButton)),
347
+ h('div', { class: 'fx-invoice__grid' },
348
+ h('div', { class: 'fx-invoice__main' },
349
+ h('section', { class: 'fx-card fx-invoice__head' },
350
+ h('label', { class: 'fx-field fx-field--client' }, h('span', { class: 'fx-label' }, 'Client'), h('div', { class: 'fx-suggest-host' }, clientInput, clientList)),
351
+ h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Reference'), reference),
352
+ h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Due date'), dueDate)),
353
+ h('section', { class: 'fx-card fx-lines' },
354
+ datalist,
355
+ h('table', { class: 'fx-lines__table' },
356
+ h('thead', {}, h('tr', {},
357
+ h('th', {}, 'Item'), h('th', {}, 'Description'), h('th', { class: 'fx-num' }, 'Qty'),
358
+ h('th', { class: 'fx-num' }, 'Unit price'), h('th', {}, 'Tax'), h('th', { class: 'fx-num' }, 'Amount'),
359
+ h('th', {}, h('span', { class: 'fx-sr' }, 'Remove')))),
360
+ tbody),
361
+ h('div', { class: 'fx-lines__foot' },
362
+ 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'),
363
+ totalsHost))),
364
+ h('aside', { class: 'fx-invoice__aside' }, jobCard(ui, invoice?.job))),
365
+ ];
366
+ }
367
+
368
+ // An issued or void invoice: the document as sent, and what can happen next.
369
+ function issuedView(invoice, ui, context, redraw) {
370
+ const { h, flow, toast } = ui;
371
+ const act = async (path, done, confirmText) => {
372
+ if (confirmText) {
373
+ const ok = await ui.dialog(confirmText);
374
+ if (!ok) return;
375
+ }
376
+ try {
377
+ const res = await flow(`/api/invoicing/invoices/${invoice.id}/${path}`, { method: 'POST', body: '{}' });
378
+ toast(done, 'success');
379
+ redraw(res.invoice);
380
+ } catch (error) {
381
+ toast(error.message, 'danger');
382
+ }
383
+ };
384
+ const actions = invoice.status === 'issued' && context.canIssue ? [
385
+ h('button', {
386
+ class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
387
+ onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: h('p', {}, 'The number stays used. A void invoice cannot be issued again.'), confirm: 'Void', tone: 'danger' }),
388
+ }, 'Void'),
389
+ h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
390
+ sendToXero(invoice, ui, context),
391
+ ] : [];
392
+ return [
393
+ crumbs(ui, invoice.number),
394
+ h('header', { class: 'fx-ph' },
395
+ h('div', { class: 'fx-ph__text' },
396
+ h('div', { class: 'fx-row' },
397
+ h('h1', { class: 'fx-title' }, invoice.number),
398
+ h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])),
399
+ h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · issued ${showDate(invoice.issueDate)}`)),
400
+ h('div', { class: 'fx-ph__actions' }, actions)),
401
+ h('div', { class: 'fx-invoice__grid' },
402
+ h('div', { class: 'fx-invoice__main' },
403
+ h('section', { class: 'fx-card fx-doc' },
404
+ h('dl', { class: 'fx-doc__meta' },
405
+ 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)),
406
+ h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
407
+ h('div', {}, h('dt', { class: 'fx-label' }, 'Issued'), h('dd', {}, showDate(invoice.issueDate))),
408
+ h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
409
+ h('table', { class: 'fx-lines__table fx-lines__table--read' },
410
+ 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'))),
411
+ h('tbody', {}, invoice.lines.map((line) => h('tr', {},
412
+ h('td', {}, line.description),
413
+ h('td', { class: 'fx-num' }, String(line.quantity)),
414
+ h('td', { class: 'fx-num' }, formatCents(line.unitAmountCents)),
415
+ h('td', { class: 'fx-dim' }, line.taxCode === 'GST' ? 'GST' : 'GST free'),
416
+ h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
417
+ h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
418
+ h('aside', { class: 'fx-invoice__aside' },
419
+ jobCard(ui, invoice.job),
420
+ invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
421
+ h('h3', { class: 'fx-label' }, 'History'),
422
+ h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
423
+ h('span', { class: 'fx-strong' }, v.version === 1 ? 'Issued' : `Reissued (version ${v.version})`),
424
+ h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
425
+ ];
426
+ }
427
+
428
+ // Send to Xero: a draft in Xero from the issued invoice (POST /api/xero/invoices/push).
429
+ function sendToXero(invoice, ui, context) {
430
+ const { h, flow, toast } = ui;
431
+ const organisationId = context.organisations[0]?.id;
432
+ if (!organisationId || !context.xero) return null;
433
+ return h('button', {
434
+ class: 'fx-btn fx-btn--primary', type: 'button',
435
+ onclick: async (event) => {
436
+ const button = event.currentTarget;
437
+ button.disabled = true;
438
+ try {
439
+ const res = await flow('/api/xero/invoices/push', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) });
440
+ const result = res.invoices?.[0];
441
+ if (['created', 'reconciled', 'existing'].includes(result?.outcome)) toast(`${invoice.number} is in Xero as a draft.`, 'success');
442
+ else if (result?.outcome === 'already_in_xero') toast(`Xero already has ${invoice.number}.`, 'info');
443
+ else toast(result?.error?.message ?? 'Xero did not take it.', 'danger');
444
+ } catch (error) {
445
+ toast(error.message, 'danger');
446
+ } finally {
447
+ button.disabled = false;
448
+ }
449
+ },
450
+ }, 'Send to Xero');
451
+ }
@@ -0,0 +1,153 @@
1
+ // Jobs (/jobs): every billable job from the lab system, as a board with one
2
+ // column per billing stage, or as a list. A job's stage follows from its
3
+ // evidence (server/core/ledger.js ledgerView), so cards are not dragged: a
4
+ // job moves when its evidence changes, or when someone drafts its invoice
5
+ // here. Data comes from /api/invoicing/jobs (server/api/invoicing.js).
6
+
7
+ import { formatCents } from '/assets/ui/invoicing.js';
8
+
9
+ // Board columns, left to right. Held jobs sit with the jobs that need review.
10
+ const STAGES = [
11
+ { id: 'review', label: 'Needs review', views: ['review_required', 'held'], tone: 'warn' },
12
+ { id: 'pending', label: 'Checking', views: ['pending'], tone: 'mute' },
13
+ { id: 'ready', label: 'Ready to bill', views: ['ready'], tone: 'info' },
14
+ { id: 'drafted', label: 'Drafted', views: ['drafted'], tone: 'info' },
15
+ { id: 'invoiced', label: 'Invoiced', views: ['invoiced'], tone: 'success' },
16
+ { id: 'paid', label: 'Paid', views: ['paid'], tone: 'success' },
17
+ ];
18
+ const STAGE_OF = Object.fromEntries(STAGES.flatMap((stage) => stage.views.map((view) => [view, stage])));
19
+ const LAYOUT_KEY = 'flow_jobs_layout';
20
+
21
+ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
22
+
23
+ function readLayout() {
24
+ try { return localStorage.getItem(LAYOUT_KEY) === 'list' ? 'list' : 'board'; } catch { return 'board'; }
25
+ }
26
+
27
+ function saveLayout(layout) {
28
+ try { localStorage.setItem(LAYOUT_KEY, layout); } catch { /* the choice is only a convenience */ }
29
+ }
30
+
31
+ export async function renderJobs(main, ui) {
32
+ const { h, flow, pageHeader, notice } = ui;
33
+ const state = { layout: readLayout(), q: '', jobs: [], canDraft: false };
34
+ const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
35
+ const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a case, client or practitioner', 'aria-label': 'Find a job' });
36
+ const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
37
+ main.classList.add('fx-main--wide');
38
+ main.append(
39
+ pageHeader('Jobs', { subtitle: 'Every billable job from the lab system, by billing stage.' }),
40
+ h('div', { class: 'fx-toolbar fx-toolbar--plain' }, search, toggle),
41
+ host);
42
+
43
+ const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
44
+ type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.layout === id),
45
+ onclick: () => { state.layout = id; saveLayout(id); drawToggle(); draw(); },
46
+ }, label)));
47
+ drawToggle();
48
+ search.addEventListener('input', () => { state.q = search.value.trim().toLowerCase(); draw(); });
49
+
50
+ const visible = () => (state.q
51
+ ? state.jobs.filter((job) => [job.caseRef, job.sourceJobRef, job.contactName, job.practitioner, job.invoiceNumber]
52
+ .some((value) => value && String(value).toLowerCase().includes(state.q)))
53
+ : state.jobs);
54
+
55
+ function draw() {
56
+ if (!state.jobs.length) {
57
+ host.replaceChildren(h('div', { class: 'fx-panel fx-empty' },
58
+ h('p', {}, 'No jobs yet. Jobs appear here once the lab system is connected under Integrations.'),
59
+ h('a', { class: 'fx-btn', href: '/invoices/new', onclick: ui.go }, 'Make an invoice by hand')));
60
+ return;
61
+ }
62
+ host.replaceChildren(state.layout === 'list' ? list(visible()) : board(visible()));
63
+ }
64
+
65
+ async function load() {
66
+ try {
67
+ const [{ jobs }, context] = await Promise.all([flow('/api/invoicing/jobs'), flow('/api/invoicing/context')]);
68
+ state.jobs = jobs;
69
+ state.canDraft = context.canIssue;
70
+ } catch (error) {
71
+ host.replaceChildren(notice(error.message, 'danger'));
72
+ return;
73
+ }
74
+ draw();
75
+ }
76
+
77
+ function action(job) {
78
+ if (job.invoiceId) {
79
+ const href = `/invoices/${job.invoiceId}`;
80
+ return h('a', { class: 'fx-btn fx-btn--ghost fx-btn--sm', href, onclick: ui.go }, job.invoiceNumber ?? 'Open draft');
81
+ }
82
+ if (job.view !== 'ready' || !state.canDraft) return null;
83
+ return h('button', {
84
+ class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button',
85
+ onclick: async (event) => {
86
+ event.stopPropagation();
87
+ const button = event.currentTarget;
88
+ button.disabled = true;
89
+ try {
90
+ const { invoice } = await flow(`/api/invoicing/jobs/${job.id}/draft`, { method: 'POST', body: '{}' });
91
+ ui.toast('Draft invoice made.', 'success');
92
+ ui.go(`/invoices/${invoice.id}`);
93
+ } catch (error) {
94
+ const reasons = error.details?.reasons;
95
+ ui.toast(reasons?.length ? `Not drafted: ${reasons.join('; ')}.` : error.message, 'danger');
96
+ button.disabled = false;
97
+ await load();
98
+ }
99
+ },
100
+ }, 'Draft invoice');
101
+ }
102
+
103
+ function card(job) {
104
+ const stage = STAGE_OF[job.view];
105
+ const reasons = ['review', 'pending'].includes(stage?.id) ? job.reasons?.join('; ') : '';
106
+ const why = reasons ? reasons[0].toUpperCase() + reasons.slice(1) + '.' : null;
107
+ return h('article', { class: `fx-job${stage?.id === 'review' ? ' fx-job--flag' : ''}` },
108
+ h('div', { class: 'fx-job__top' },
109
+ h('span', { class: 'fx-strong' }, job.caseRef ?? job.sourceJobRef),
110
+ h('span', { class: 'fx-job__amount' }, formatCents(job.totalCents))),
111
+ h('div', { class: 'fx-job__client' }, job.contactName ?? 'No client'),
112
+ h('div', { class: 'fx-job__meta' },
113
+ job.practitioner ? h('span', {}, job.practitioner) : null,
114
+ h('span', {}, `Shipped ${showDate(job.shippedOn)}`)),
115
+ why ? h('p', { class: 'fx-job__why' }, why) : null,
116
+ action(job));
117
+ }
118
+
119
+ function board(jobs) {
120
+ return h('div', { class: 'fx-board' }, STAGES.map((stage) => {
121
+ const inStage = jobs.filter((job) => stage.views.includes(job.view));
122
+ const total = inStage.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
123
+ return h('section', { class: 'fx-board__col', 'aria-label': stage.label },
124
+ h('header', { class: 'fx-board__head' },
125
+ h('span', { class: 'fx-board__dot', 'data-tone': stage.tone }),
126
+ h('span', { class: 'fx-strong' }, stage.label),
127
+ h('span', { class: 'fx-board__count' }, String(inStage.length)),
128
+ h('span', { class: 'fx-board__sum' }, inStage.length ? formatCents(total) : '')),
129
+ h('div', { class: 'fx-board__cards' }, inStage.length ? inStage.map(card) : h('p', { class: 'fx-board__none' }, 'None')));
130
+ }));
131
+ }
132
+
133
+ function list(jobs) {
134
+ if (!jobs.length) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches that search.'));
135
+ return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table' },
136
+ h('thead', {}, h('tr', {},
137
+ h('th', {}, 'Case'), h('th', {}, 'Client'), h('th', {}, 'Practitioner'), h('th', {}, 'Shipped'),
138
+ h('th', {}, 'Stage'), h('th', { class: 'fx-num' }, 'Total'), h('th', { class: 'fx-table__actions' }, h('span', { class: 'fx-sr' }, 'Action')))),
139
+ h('tbody', {}, jobs.map((job) => {
140
+ const stage = STAGE_OF[job.view];
141
+ return h('tr', {},
142
+ h('td', { class: 'fx-strong' }, job.caseRef ?? job.sourceJobRef),
143
+ h('td', {}, job.contactName ?? '—'),
144
+ h('td', { class: 'fx-dim' }, job.practitioner ?? '—'),
145
+ h('td', { class: 'fx-dim' }, showDate(job.shippedOn)),
146
+ h('td', {}, h('span', { class: 'fx-status', 'data-tone': stage?.tone ?? 'mute', title: job.reasons?.join('; ') ?? '' }, stage?.label ?? job.view)),
147
+ h('td', { class: 'fx-num fx-strong' }, formatCents(job.totalCents)),
148
+ h('td', { class: 'fx-table__actions' }, action(job)));
149
+ }))));
150
+ }
151
+
152
+ await load();
153
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img"><title>Seazona</title><path fill="#F04037" d="M6 3H26L22 13H21L18 24H22L23 23V27L22 29H8L9 25L11 19V13H7L6 9Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#13B5EA"><title>Xero</title><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm6.585 14.655c-1.485 0-2.69-1.206-2.69-2.689 0-1.485 1.207-2.691 2.69-2.691 1.485 0 2.69 1.207 2.69 2.691s-1.207 2.689-2.69 2.689zM7.53 14.644c-.099 0-.192-.041-.267-.116l-2.043-2.04-2.052 2.047c-.069.068-.16.108-.258.108-.202 0-.368-.166-.368-.368 0-.099.04-.191.111-.263l2.04-2.05-2.038-2.047c-.075-.069-.113-.162-.113-.261 0-.203.166-.366.368-.366.098 0 .188.037.258.105l2.055 2.048 2.048-2.045c.069-.071.162-.108.26-.108.211 0 .375.165.375.366 0 .098-.029.188-.104.258l-2.056 2.055 2.055 2.051c.068.069.104.16.104.258 0 .202-.165.368-.365.368h-.01zm8.017-4.591c-.796.101-.882.476-.882 1.404v2.787c0 .202-.165.366-.366.366-.203 0-.367-.165-.368-.366v-4.53c0-.204.16-.366.362-.366.166 0 .316.125.346.289.27-.209.6-.317.93-.317h.105c.195 0 .359.165.359.368 0 .201-.164.352-.375.359 0 0-.09 0-.164.008l.053-.002zm-3.091 2.205H8.625c0 .019.003.037.006.057.02.105.045.211.083.31.194.531.765 1.275 1.829 1.29.33-.003.631-.086.9-.229.21-.12.391-.271.525-.428.045-.058.09-.112.12-.168.18-.229.405-.186.54-.083.164.135.18.391.045.57l-.016.016c-.21.27-.435.495-.689.66-.255.164-.525.284-.811.345-.33.09-.645.104-.975.06-1.095-.135-2.01-.93-2.28-2.01-.06-.21-.09-.42-.09-.645 0-.855.421-1.695 1.125-2.205.885-.615 2.085-.66 3-.075.63.405 1.035 1.021 1.185 1.771.075.419-.21.794-.734.81l.068-.046zm6.129-2.223c-1.064 0-1.931.865-1.931 1.931 0 1.064.866 1.931 1.931 1.931s1.931-.867 1.931-1.931c0-1.065-.866-1.933-1.931-1.933v.002zm0 2.595c-.367 0-.666-.297-.666-.666 0-.367.3-.665.666-.665.367 0 .667.299.667.665 0 .369-.3.667-.667.666zm-8.04-2.603c-.91 0-1.672.623-1.886 1.466v.03h3.776c-.203-.855-.973-1.494-1.891-1.494v-.002z"/></svg>
@@ -17,8 +17,10 @@ import { themeStylesheet } from '../theme/themes.js';
17
17
  const require = createRequire(import.meta.url);
18
18
  const here = dirname(fileURLToPath(import.meta.url));
19
19
  const themeDir = join(here, '..', 'theme');
20
+ const logoDir = join(here, 'logos');
21
+ const LOGO_FILES = new Set(['xero.svg', 'seazona.svg']);
20
22
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
21
- const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'flow-mark.svg']);
23
+ const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'jobs.js', 'flow-mark.svg']);
22
24
  const MAX_AGE = '1h';
23
25
 
24
26
  // Send one named file from dir. Passing dir as root limits the dotfile check
@@ -44,6 +46,8 @@ export function createUiAssetsRouter({ rateLimitPerMinute = 600 } = {}) {
44
46
  };
45
47
  router.get('/assets/theme/:file', sendFrom(themeDir, THEME_FILES));
46
48
  router.get('/assets/ui/:file', sendFrom(here, UI_FILES));
49
+ // Each integration's logo, the maker's own mark (Integrations).
50
+ router.get('/assets/ui/logos/:file', sendFrom(logoDir, LOGO_FILES));
47
51
 
48
52
  for (const name of FONT_PACKAGES) {
49
53
  const dir = dirname(require.resolve(`@fontsource-variable/${name}/LICENSE`));