@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.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/custom-fields.js +2 -1
- package/server/api/invoicing.js +61 -3
- package/server/auth/clerk.js +35 -0
- package/server/auth/routes.js +45 -6
- package/server/core/custom-fields.js +18 -0
- package/server/core/host.js +24 -0
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +80 -1
- package/server/index.js +9 -3
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/seazona/index.js +1 -1
- package/server/integrations/seazona/settings.js +5 -2
- package/server/integrations/sentry/index.js +5 -2
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +4 -4
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/reconcile.js +17 -11
- package/server/integrations/xero/routes.js +42 -0
- package/server/integrations/xero/settings.js +12 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +142 -23
- package/server/ui/bulk-bar.js +100 -0
- package/server/ui/flow.css +225 -11
- package/server/ui/invoicing.js +234 -30
- package/server/ui/job-board.js +60 -34
- package/server/ui/job-view.js +30 -7
- package/server/ui/jobs.js +469 -38
- package/server/ui/reconcile.js +275 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-import.js +329 -0
package/server/ui/invoicing.js
CHANGED
|
@@ -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.
|
|
26
|
+
ready: 'Nothing is ready. In Drafts, tick drafts and press Ready.',
|
|
26
27
|
};
|
|
27
|
-
//
|
|
28
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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' },
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
439
|
-
|
|
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
|
-
|
|
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' }, '
|
|
444
|
-
h('
|
|
445
|
-
|
|
446
|
-
|
|
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,
|
|
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
|
-
|
|
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)),
|
package/server/ui/job-board.js
CHANGED
|
@@ -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
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
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
|
|
108
|
-
|
|
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.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
export
|
|
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
|
|
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,
|
|
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
|
|
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) {
|
package/server/ui/job-view.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
|
|
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,
|