@arcforgelabs/flow 2026.10.33 → 2026.10.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +115 -0
- package/server/api/invoicing.js +43 -12
- package/server/api/work-moves.js +80 -0
- package/server/app.js +12 -0
- package/server/auth/people.js +35 -0
- package/server/auth/routes.js +14 -0
- package/server/core/custom-fields.js +308 -0
- package/server/core/job-board.js +4 -0
- package/server/core/ledger.js +82 -2
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/0019_people_views.sql +1 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/0019_snapshot.json +4295 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +64 -1
- package/server/index.js +6 -1
- package/server/integrations/port.js +8 -1
- package/server/integrations/seazona/index.js +60 -2
- package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
- package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
- package/server/integrations/seazona/schema.js +26 -0
- package/server/integrations/seazona/settings.js +35 -0
- package/server/integrations/seazona/stage-moves.js +133 -0
- package/server/integrations/seazona/tables.js +2 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/release-channel.js +8 -6
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +40 -2
- package/server/ui/custom-fields.js +190 -0
- package/server/ui/flow.css +128 -10
- package/server/ui/invoice-notes.js +1 -1
- package/server/ui/invoicing.js +185 -48
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +78 -0
- package/server/ui/job-view.js +119 -0
- package/server/ui/jobs.js +482 -49
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
package/server/ui/invoicing.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// (server/api/invoicing.js). app.js passes in its shared pieces as `ui`.
|
|
8
8
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
9
9
|
import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
|
|
10
|
+
import { jobChangeBanner } from '/assets/ui/job-change.js';
|
|
10
11
|
|
|
11
12
|
const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
|
|
12
13
|
const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
|
|
@@ -21,7 +22,7 @@ const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_che
|
|
|
21
22
|
const EMPTY = {
|
|
22
23
|
to_check: 'No drafts to check. Press New invoice to start one.',
|
|
23
24
|
problem: 'No problems.',
|
|
24
|
-
ready: 'Nothing is ready. Tick drafts and press
|
|
25
|
+
ready: 'Nothing is ready. Tick drafts and press Move to Ready.',
|
|
25
26
|
};
|
|
26
27
|
// Tabs whose rows can be ticked, and what the ticked rows can do.
|
|
27
28
|
const TICKS = { to_check: true, ready: true };
|
|
@@ -137,9 +138,9 @@ export async function renderInvoiceList(main, ui) {
|
|
|
137
138
|
const ticked = [...state.ticked];
|
|
138
139
|
const parts = [];
|
|
139
140
|
if (state.status === 'to_check') {
|
|
140
|
-
parts.push(h('p', { class: 'fx-dim' }, ticked.length ? `${plural(ticked.length, 'draft', 'drafts')} ticked.` : 'Check each draft, tick it, then
|
|
141
|
+
parts.push(h('p', { class: 'fx-dim' }, ticked.length ? `${plural(ticked.length, 'draft', 'drafts')} ticked.` : 'Check each draft, tick it, then move it to Ready.'));
|
|
141
142
|
if (ticked.length) {
|
|
142
|
-
parts.push(action(`
|
|
143
|
+
parts.push(action(`Move ${ticked.length} to Ready`, 'mark-ready', () => ({ invoiceIds: ticked }), (res) => (res.refused.length
|
|
143
144
|
? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
|
|
144
145
|
: [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
|
|
145
146
|
}
|
|
@@ -151,7 +152,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
151
152
|
(res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
|
|
152
153
|
}
|
|
153
154
|
if (context.canIssue && body.counts.ready > 0) {
|
|
154
|
-
buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `
|
|
155
|
+
buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Finalise all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
|
|
155
156
|
(res) => [`${res.issued} issued.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
|
|
156
157
|
}
|
|
157
158
|
if (buttons.length) parts.push(h('div', { class: 'fx-sendbar__actions' }, buttons));
|
|
@@ -226,9 +227,13 @@ export async function renderInvoiceList(main, ui) {
|
|
|
226
227
|
// The invoice page steps through this list, in this order (navBar).
|
|
227
228
|
saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
|
|
228
229
|
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
|
|
230
|
+
const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
|
|
229
231
|
const ticks = TICKS[state.status];
|
|
230
232
|
const showProblem = state.status === 'problem';
|
|
231
233
|
const created = ['to_check', 'problem', 'ready'].includes(state.status);
|
|
234
|
+
// 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.
|
|
236
|
+
const numbered = !created;
|
|
232
237
|
const boxes = [];
|
|
233
238
|
const tickBox = (invoice) => {
|
|
234
239
|
const box = h('input', {
|
|
@@ -252,7 +257,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
252
257
|
host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
|
|
253
258
|
h('thead', {}, h('tr', {},
|
|
254
259
|
ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
|
|
255
|
-
h('th', {}, 'Number'), h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
|
|
260
|
+
numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
|
|
256
261
|
h('th', {}, created ? 'Created' : 'Issued'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'))),
|
|
257
262
|
h('tbody', {}, body.invoices.map((invoice) => {
|
|
258
263
|
const href = `/invoices/${invoice.id}`;
|
|
@@ -260,8 +265,8 @@ export async function renderInvoiceList(main, ui) {
|
|
|
260
265
|
const view = invoice.status === 'void' ? null : invoice.view;
|
|
261
266
|
return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
|
|
262
267
|
ticks ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)) : null,
|
|
263
|
-
h('td', {},
|
|
264
|
-
h('td', {}, invoice.contactName ?? '—'),
|
|
268
|
+
numbered ? h('td', {}, link(href, invoice.number ?? 'Draft')) : null,
|
|
269
|
+
h('td', {}, numbered ? (invoice.contactName ?? '—') : link(href, invoice.contactName ?? 'No client')),
|
|
265
270
|
showProblem
|
|
266
271
|
? h('td', { class: 'fx-problem' }, first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1} more`) : null)
|
|
267
272
|
: h('td', { class: 'fx-dim' }, invoice.reference ?? '—'),
|
|
@@ -301,12 +306,12 @@ function saveNav(nav) {
|
|
|
301
306
|
}
|
|
302
307
|
|
|
303
308
|
// The bar above one invoice: Back to the list as it was, and the previous and
|
|
304
|
-
// next invoice of that list.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
const
|
|
309
|
+
// next invoice of that list. An invoice opened from elsewhere (the job board,
|
|
310
|
+
// search, a link) steps through its own tab: Drafts, Ready, Problem, Issued
|
|
311
|
+
// or Void, read once here.
|
|
312
|
+
function navBar(ui, invoice) {
|
|
313
|
+
const { h, go, flow } = ui;
|
|
314
|
+
const id = invoice?.id ?? null;
|
|
310
315
|
const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
|
|
311
316
|
const step = (to, label, path) => {
|
|
312
317
|
const button = h('button', {
|
|
@@ -316,15 +321,33 @@ function navBar(ui, id) {
|
|
|
316
321
|
button.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
317
322
|
return button;
|
|
318
323
|
};
|
|
319
|
-
|
|
320
|
-
h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back')
|
|
321
|
-
|
|
322
|
-
at
|
|
324
|
+
const bar = h('nav', { class: 'fx-pager', 'aria-label': 'Invoices' },
|
|
325
|
+
h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back'));
|
|
326
|
+
const fill = (ids) => {
|
|
327
|
+
const at = ids.indexOf(id);
|
|
328
|
+
if (at < 0) return false;
|
|
329
|
+
bar.replaceChildren(bar.firstChild,
|
|
330
|
+
h('span', { class: 'fx-pager__count' }, `${at + 1} of ${ids.length}`),
|
|
331
|
+
h('span', { class: 'fx-pager__steps' }, step(ids[at - 1], 'Previous invoice', 'M14.5 6l-6 6 6 6'), step(ids[at + 1], 'Next invoice', 'M9.5 6l6 6-6 6')));
|
|
332
|
+
return true;
|
|
333
|
+
};
|
|
334
|
+
const tab = invoice?.status === 'void' ? 'void' : invoice?.view ?? invoice?.status;
|
|
335
|
+
if (id && !fill(readNav()?.ids ?? []) && TABS.some(([t]) => t === tab)) {
|
|
336
|
+
flow(`/api/invoicing/invoices?${new URLSearchParams({ status: tab })}`).then((body) => {
|
|
337
|
+
const ids = body.invoices.map((i) => i.id);
|
|
338
|
+
if (!fill(ids)) return;
|
|
339
|
+
saveNav({ tab, q: '', ids });
|
|
340
|
+
try { sessionStorage.setItem('flow_invoice_tab', tab); } catch { /* Back opens the last tab */ }
|
|
341
|
+
}).catch(() => { /* Back only */ });
|
|
342
|
+
}
|
|
343
|
+
return bar;
|
|
323
344
|
}
|
|
324
345
|
|
|
325
|
-
// After a send,
|
|
326
|
-
//
|
|
327
|
-
//
|
|
346
|
+
// After a send, a Finalise or a Save as draft, the next invoice of the list
|
|
347
|
+
// opens; at its end, the list. So a person reviews drafts one after another
|
|
348
|
+
// without going back. The invoice stays in the bar's list, so the count holds
|
|
349
|
+
// (2 of 35) and Previous opens it again, now done. Back reloads the list
|
|
350
|
+
// without it.
|
|
328
351
|
export function openNext(ui, id) {
|
|
329
352
|
const ids = readNav()?.ids ?? [];
|
|
330
353
|
const at = ids.indexOf(id);
|
|
@@ -389,6 +412,8 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
389
412
|
const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
|
|
390
413
|
if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
|
|
391
414
|
let contact = invoice?.contact ?? null;
|
|
415
|
+
// Finalise invoice waits for a client and a due date (syncIssue, below).
|
|
416
|
+
let onContact = () => {};
|
|
392
417
|
|
|
393
418
|
// Client: type to find a customer.
|
|
394
419
|
const clientInput = h('input', { class: 'fx-input', type: 'text', autocomplete: 'off', placeholder: 'Type a client name', value: contact?.name ?? '', 'aria-label': 'Client' });
|
|
@@ -410,6 +435,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
410
435
|
contact = c;
|
|
411
436
|
clientInput.value = c.name;
|
|
412
437
|
clientList.hidden = true;
|
|
438
|
+
onContact();
|
|
413
439
|
},
|
|
414
440
|
}, 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.')]));
|
|
415
441
|
clientList.hidden = false;
|
|
@@ -420,7 +446,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
420
446
|
});
|
|
421
447
|
clientInput.addEventListener('blur', () => { setTimeout(() => { clientList.hidden = true; }, 120); });
|
|
422
448
|
|
|
423
|
-
const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: '
|
|
449
|
+
const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: 'Practitioner - Patient' });
|
|
424
450
|
const dueDate = h('input', { class: 'fx-input', type: 'date', value: invoice?.dueDate ?? '' });
|
|
425
451
|
|
|
426
452
|
// Lines.
|
|
@@ -538,24 +564,28 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
538
564
|
};
|
|
539
565
|
|
|
540
566
|
// Save shows only when there is something to save: a new invoice, or a
|
|
541
|
-
// change since the page opened.
|
|
567
|
+
// change since the page opened. Save as draft opens the next invoice.
|
|
568
|
+
// Save as draft: a draft that is not ready always offers it. A ready
|
|
569
|
+
// invoice offers Save changes only once something changed.
|
|
570
|
+
const keepsSave = isNew || invoice?.view !== 'ready';
|
|
542
571
|
const saveButton = h('button', {
|
|
543
|
-
class: 'fx-btn', type: 'button', hidden: !
|
|
572
|
+
class: 'fx-btn', type: 'button', hidden: !keepsSave,
|
|
544
573
|
onclick: async () => {
|
|
545
574
|
const saved = await save();
|
|
546
575
|
if (!saved) return;
|
|
547
|
-
toast(
|
|
576
|
+
toast(keepsSave ? 'Saved as a draft.' : 'Changes saved.', 'success');
|
|
548
577
|
if (isNew) go(`/invoices/${saved.id}`, { replace: true });
|
|
578
|
+
else if (keepsSave) openNext(ui, invoice.id);
|
|
549
579
|
else redraw(saved);
|
|
550
580
|
},
|
|
551
|
-
},
|
|
581
|
+
}, keepsSave ? 'Save as draft' : 'Save changes');
|
|
552
582
|
const opened = JSON.stringify(body());
|
|
553
583
|
const changed = () => !isNew && JSON.stringify(body()) !== opened;
|
|
554
|
-
const watchChanges = () => { if (!
|
|
584
|
+
const watchChanges = () => { if (!keepsSave) saveButton.hidden = !changed(); };
|
|
555
585
|
// With an accounting system on, a ready invoice is sent there: Flow issues
|
|
556
586
|
// it and queues it, the same as Send all ready. With none, Flow issues it
|
|
557
|
-
// and keeps it. A draft that is not ready has no send:
|
|
558
|
-
const to = invoice?.sendsTo ?? null;
|
|
587
|
+
// and keeps it. A draft that is not ready has no send: move it to Ready first.
|
|
588
|
+
const to = invoice?.sendsTo ?? context.sendsTo ?? null;
|
|
559
589
|
const ready = invoice?.view === 'ready';
|
|
560
590
|
const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
|
|
561
591
|
const emails = invoice?.sendMode?.mode === 'approve_and_email';
|
|
@@ -579,45 +609,80 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
579
609
|
}
|
|
580
610
|
},
|
|
581
611
|
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|
|
612
|
+
// With no accounting system, Finalise invoice gives the draft its number and
|
|
613
|
+
// locks it. A changed job blocks it, as it blocks Move to Ready.
|
|
582
614
|
const issueButton = context.canIssue && !to ? h('button', {
|
|
583
615
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
584
616
|
onclick: async () => {
|
|
585
617
|
const saved = await save();
|
|
586
618
|
if (!saved) return;
|
|
587
619
|
const ok = await ui.dialog({
|
|
588
|
-
title: '
|
|
620
|
+
title: 'Finalise this invoice?',
|
|
589
621
|
body: h('p', {}, `It gets the next number (${nextNumber}). Flow keeps it exactly as issued. To change it later, revise it.`),
|
|
590
|
-
confirm: '
|
|
622
|
+
confirm: 'Finalise',
|
|
591
623
|
});
|
|
592
624
|
if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
|
|
593
625
|
try {
|
|
594
626
|
const { invoice: issued } = await flow(`/api/invoicing/invoices/${saved.id}/issue`, { method: 'POST', body: JSON.stringify({ issueDate: localDate() }) });
|
|
595
|
-
toast(`${issued.number}
|
|
627
|
+
toast(`${issued.number} is final.`, 'success');
|
|
596
628
|
if (isNew) go(`/invoices/${issued.id}`, { replace: true });
|
|
597
|
-
else
|
|
629
|
+
else openNext(ui, invoice.id);
|
|
598
630
|
} catch (error) {
|
|
599
631
|
toast(error.message, 'danger');
|
|
600
632
|
}
|
|
601
633
|
},
|
|
602
|
-
}, '
|
|
603
|
-
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
634
|
+
}, 'Finalise') : null;
|
|
635
|
+
// Issue needs a client and a due date: until both are set it is greyed.
|
|
636
|
+
// A changed job greys it too, as it does Move to Ready.
|
|
637
|
+
const syncIssue = () => {
|
|
638
|
+
if (!issueButton) return;
|
|
639
|
+
const missing = [contact ? null : 'a client', dueDate.value ? null : 'a due date'].filter(Boolean);
|
|
640
|
+
issueButton.disabled = missing.length > 0 || Boolean(invoice?.jobChange);
|
|
641
|
+
issueButton.title = invoice?.jobChange ? 'Fix the problem first.' : missing.length ? `Choose ${missing.join(' and ')} first.` : 'Gives it the next number and locks it. Nothing is sent.';
|
|
642
|
+
};
|
|
643
|
+
onContact = syncIssue;
|
|
644
|
+
clientInput.addEventListener('input', syncIssue);
|
|
645
|
+
dueDate.addEventListener('input', syncIssue);
|
|
646
|
+
dueDate.addEventListener('change', syncIssue);
|
|
647
|
+
syncIssue();
|
|
648
|
+
|
|
649
|
+
// A new invoice can start from a job that has no invoice yet: a quiet
|
|
650
|
+
// strip above the form says so. Flow drafts it the way the invoicing rules
|
|
651
|
+
// do (client, Reference, lines) and links it to the job. An invoice with
|
|
652
|
+
// no job is fine too.
|
|
653
|
+
const fromJob = isNew && context.canIssue ? h('section', { class: 'fx-fromjob' },
|
|
654
|
+
h('div', { class: 'fx-fromjob__text' },
|
|
655
|
+
h('span', { class: 'fx-strong' }, 'Billing a job?'),
|
|
656
|
+
h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
|
|
657
|
+
h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
|
|
658
|
+
|
|
659
|
+
// Finalise, with an accounting system on: the draft is saved, then Flow
|
|
660
|
+
// checks it and moves it to the Ready tab, for Send all ready; nothing is
|
|
661
|
+
// sent here. A draft the draft routine made from a job ready to invoice is
|
|
662
|
+
// in Ready already (automations.js), so this is for the rest. One with a
|
|
663
|
+
// problem stays a draft. Move to Drafts, on the Status card, undoes it. A
|
|
664
|
+
// changed job is fixed at its source or adopted, so the button waits.
|
|
665
|
+
const jobChange = invoice?.jobChange ?? null;
|
|
666
|
+
const markReadyButton = isNew || ready || !to ? null : h('button', {
|
|
667
|
+
class: 'fx-btn fx-btn--primary', type: 'button', disabled: Boolean(jobChange),
|
|
668
|
+
title: jobChange ? 'Fix the problem first.' : `Checks this draft and moves it to Ready. Send all ready sends it to ${to}.`,
|
|
608
669
|
onclick: async () => {
|
|
609
670
|
if (!(await save())) return;
|
|
610
671
|
try {
|
|
611
672
|
const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
|
|
612
|
-
if (res.marked)
|
|
613
|
-
|
|
673
|
+
if (res.marked) {
|
|
674
|
+
toast(`Final. It is in Ready; Send all ready sends it to ${to}.`, 'success');
|
|
675
|
+
openNext(ui, invoice.id);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
|
|
614
679
|
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
615
680
|
redraw(fresh);
|
|
616
681
|
} catch (error) {
|
|
617
682
|
toast(error.message, 'danger');
|
|
618
683
|
}
|
|
619
684
|
},
|
|
620
|
-
}, '
|
|
685
|
+
}, 'Finalise');
|
|
621
686
|
// A ready invoice can go back to Drafts. It is a status change, so it sits
|
|
622
687
|
// with the status, not with the page's main buttons.
|
|
623
688
|
const statusCard = ready ? h('section', { class: 'fx-card fx-side-card' },
|
|
@@ -636,18 +701,42 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
636
701
|
}
|
|
637
702
|
},
|
|
638
703
|
}, 'Move to Drafts')) : null;
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
704
|
+
// The job changed after the invoice was made: its own banner shows what
|
|
705
|
+
// changed. Other problems stay one line.
|
|
706
|
+
const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
|
|
707
|
+
const problems = [
|
|
708
|
+
jobChange ? jobChangeBanner(ui, jobChange, {
|
|
709
|
+
money: formatCents,
|
|
710
|
+
// Adopting changes the draft for the business: owners and admins only.
|
|
711
|
+
onAdopt: !context.canIssue ? null : async () => {
|
|
712
|
+
const system = jobChange.systemName;
|
|
713
|
+
const ok = await ui.dialog({
|
|
714
|
+
title: `Adopt the ${system} changes?`,
|
|
715
|
+
body: h('p', {}, `This invoice takes the Now in ${system} column.${changed() ? ' Your unsaved changes here are lost.' : ''} Nothing goes to ${system} or the accounting system.`),
|
|
716
|
+
confirm: 'Adopt changes',
|
|
717
|
+
});
|
|
718
|
+
if (!ok) return;
|
|
719
|
+
try {
|
|
720
|
+
const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body: '{}' });
|
|
721
|
+
toast(`The invoice now matches ${system}.${res.invoice.view === 'ready' ? ' It is in Ready.' : ''}`, 'success');
|
|
722
|
+
redraw(res.invoice);
|
|
723
|
+
} catch (error) {
|
|
724
|
+
toast(error.message, 'danger');
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
}) : null,
|
|
728
|
+
otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
|
|
729
|
+
];
|
|
642
730
|
|
|
643
731
|
return [
|
|
644
|
-
navBar(ui, invoice
|
|
732
|
+
navBar(ui, invoice),
|
|
645
733
|
h('header', { class: 'fx-ph' },
|
|
646
734
|
h('div', { class: 'fx-ph__text' },
|
|
647
735
|
h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
|
|
648
736
|
h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
|
|
649
|
-
h('div', { class: 'fx-ph__actions' },
|
|
650
|
-
problems,
|
|
737
|
+
h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, markReadyButton, sendButton ?? issueButton)),
|
|
738
|
+
...problems,
|
|
739
|
+
fromJob,
|
|
651
740
|
h('div', { class: 'fx-invoice__grid' },
|
|
652
741
|
h('div', { class: 'fx-invoice__main' },
|
|
653
742
|
h('section', { class: 'fx-card fx-invoice__head' },
|
|
@@ -748,7 +837,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
748
837
|
h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
|
|
749
838
|
] : [];
|
|
750
839
|
return [
|
|
751
|
-
navBar(ui, invoice
|
|
840
|
+
navBar(ui, invoice),
|
|
752
841
|
h('header', { class: 'fx-ph' },
|
|
753
842
|
h('div', { class: 'fx-ph__text' },
|
|
754
843
|
h('div', { class: 'fx-row' },
|
|
@@ -871,3 +960,51 @@ function showSent(button) {
|
|
|
871
960
|
button.textContent = 'Sent to Xero';
|
|
872
961
|
button.title = 'Flow sent this invoice to Xero.';
|
|
873
962
|
}
|
|
963
|
+
|
|
964
|
+
// The jobs a new invoice can start from: no invoice yet, newest first.
|
|
965
|
+
const PICKABLE = new Set(['in_work', 'ready', 'pending']);
|
|
966
|
+
|
|
967
|
+
async function pickJob(ui) {
|
|
968
|
+
const { h, flow, toast, go } = ui;
|
|
969
|
+
let jobs;
|
|
970
|
+
try {
|
|
971
|
+
jobs = (await flow('/api/invoicing/jobs?limit=500')).jobs.filter((job) => PICKABLE.has(job.view) && !job.invoiceId);
|
|
972
|
+
} catch (error) {
|
|
973
|
+
toast(error.message, 'danger');
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const search = h('input', { class: 'fx-input', type: 'search', placeholder: 'Find a client or practitioner', 'aria-label': 'Find a job' });
|
|
977
|
+
const rows = jobs.map((job) => {
|
|
978
|
+
const label = h('label', { class: 'fx-pick__row' },
|
|
979
|
+
h('input', { type: 'radio', name: 'job', value: job.id, required: true }),
|
|
980
|
+
h('span', { class: 'fx-pick__main' },
|
|
981
|
+
h('span', { class: 'fx-strong' }, job.contactName ?? 'No client yet'),
|
|
982
|
+
h('span', { class: 'fx-dim' }, [job.practitioner, job.shippedOn ? showDate(job.shippedOn) : null].filter(Boolean).join(' · '))),
|
|
983
|
+
h('span', { class: 'fx-num fx-strong' }, job.totalCents == null ? '—' : formatCents(job.totalCents)));
|
|
984
|
+
label.dataset.find = `${job.contactName ?? ''} ${job.practitioner ?? ''}`.toLowerCase();
|
|
985
|
+
return label;
|
|
986
|
+
});
|
|
987
|
+
const list = h('div', { class: 'fx-pick', role: 'radiogroup', 'aria-label': 'Jobs' },
|
|
988
|
+
rows.length ? rows : h('p', { class: 'fx-dim' }, 'Every job has an invoice already.'));
|
|
989
|
+
search.addEventListener('input', () => {
|
|
990
|
+
const q = search.value.trim().toLowerCase();
|
|
991
|
+
for (const row of rows) row.hidden = Boolean(q) && !row.dataset.find.includes(q);
|
|
992
|
+
});
|
|
993
|
+
const picked = await ui.dialog({
|
|
994
|
+
title: 'New invoice from a job',
|
|
995
|
+
body: h('div', { class: 'fx-pick__body' },
|
|
996
|
+
h('p', { class: 'fx-dim' }, 'Flow fills in the client, Reference and lines from the invoice rules, and links the invoice to the job.'),
|
|
997
|
+
search, list),
|
|
998
|
+
confirm: 'Make draft',
|
|
999
|
+
});
|
|
1000
|
+
const jobId = picked?.get('job');
|
|
1001
|
+
if (!jobId) return;
|
|
1002
|
+
try {
|
|
1003
|
+
const { invoice } = await flow(`/api/invoicing/jobs/${jobId}/draft`, { method: 'POST', body: '{}' });
|
|
1004
|
+
toast('Draft invoice made from the job.', 'success');
|
|
1005
|
+
go(`/invoices/${invoice.id}`, { replace: true });
|
|
1006
|
+
} catch (error) {
|
|
1007
|
+
const reasons = error.details?.reasons;
|
|
1008
|
+
toast(reasons?.length ? `Not drafted: ${reasons.join('; ')}.` : error.message, 'danger');
|
|
1009
|
+
}
|
|
1010
|
+
}
|
package/server/ui/job-board.js
CHANGED
|
@@ -91,7 +91,7 @@ function reasonFix(reason, acct) {
|
|
|
91
91
|
if (reason === 'the work system report is out of date') {
|
|
92
92
|
return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.' };
|
|
93
93
|
}
|
|
94
|
-
if (reason === 'the job changed after its invoice was made') return fix('Job changed', '
|
|
94
|
+
if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Open the invoice to see what changed. Fix the job in the work system, or adopt its changes on the draft.');
|
|
95
95
|
if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.');
|
|
96
96
|
if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.');
|
|
97
97
|
if (/^the accounting evidence is ambiguous/.test(reason)) return fix(`${acct} match unclear`, `Find the invoice for this job in ${acct}, then link it.`);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The "job changed" problem on a draft invoice (server/core/ledger.js,
|
|
2
|
+
// jobChangeFor): what the job was when the invoice was made and what the
|
|
3
|
+
// work system has now, one row per changed fact, with "Open in <system>" to
|
|
4
|
+
// fix it there and "Adopt <system> changes" to take the new values into
|
|
5
|
+
// Flow's own draft. Flow never writes to the work system. Kept apart from
|
|
6
|
+
// invoicing.js so tests can load it without a browser.
|
|
7
|
+
|
|
8
|
+
// Rows shown before "Show all".
|
|
9
|
+
export const FIRST_ROWS = 5;
|
|
10
|
+
|
|
11
|
+
const lineText = (line) => [line.code, line.description].filter(Boolean).join(' · ');
|
|
12
|
+
const lineFull = (line, money) => `${lineText(line)} · ${line.quantity} × ${line.unitAmountCents == null ? '—' : money(line.unitAmountCents)}`;
|
|
13
|
+
|
|
14
|
+
// [{ field, was, now }] in plain words. `money` formats cents.
|
|
15
|
+
export function changeRows(change, money) {
|
|
16
|
+
const rows = [];
|
|
17
|
+
if (change.contact) rows.push({ field: 'Client', was: change.contact.was ?? '—', now: change.contact.now ?? '—' });
|
|
18
|
+
for (const line of change.lines ?? []) {
|
|
19
|
+
const at = `Line ${line.position}`;
|
|
20
|
+
if (line.change === 'added') { rows.push({ field: `${at} · added`, was: '—', now: lineFull(line.now, money) }); continue; }
|
|
21
|
+
if (line.change === 'removed') { rows.push({ field: `${at} · removed`, was: lineFull(line.was, money), now: '—' }); continue; }
|
|
22
|
+
if (line.fields.includes('item')) rows.push({ field: `${at} · item`, was: lineText(line.was), now: lineText(line.now) });
|
|
23
|
+
if (line.fields.includes('description')) rows.push({ field: `${at} · description`, was: line.was.description, now: line.now.description });
|
|
24
|
+
if (line.fields.includes('quantity')) rows.push({ field: `${at} · qty`, was: String(line.was.quantity), now: String(line.now.quantity), num: true });
|
|
25
|
+
if (line.fields.includes('price')) {
|
|
26
|
+
rows.push({ field: `${at} · price`, was: line.was.unitAmountCents == null ? '—' : money(line.was.unitAmountCents), now: line.now.unitAmountCents == null ? '—' : money(line.now.unitAmountCents), num: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const { was, now } = change.total ?? {};
|
|
30
|
+
if (was != null && now != null && was !== now) rows.push({ field: 'Total', was: money(was), now: money(now), num: true });
|
|
31
|
+
return rows;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The sentence after "Problem." One fact per sentence.
|
|
35
|
+
export function changeSummary(change, rowCount) {
|
|
36
|
+
const system = change.systemName ?? 'the work system';
|
|
37
|
+
const count = `${rowCount} ${rowCount === 1 ? 'change' : 'changes'}.`;
|
|
38
|
+
if (change.canAdopt) return `The job changed in ${system} after this invoice was made. ${count}`;
|
|
39
|
+
if (change.sent) return `The job changed in ${system} after this invoice was sent. ${count} Fix it in ${system} or in the accounting system.`;
|
|
40
|
+
return `The job changed in ${system}, and this draft changed too. ${count} Fix it in ${system} or in this draft.`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The banner. `onAdopt` runs "Adopt changes"; it is shown only when only the
|
|
44
|
+
// work system changed and the draft is not sent (ledger.js, outcome offer).
|
|
45
|
+
export function jobChangeBanner(ui, change, { money, onAdopt = null }) {
|
|
46
|
+
const { h } = ui;
|
|
47
|
+
const system = change.systemName ?? 'the work system';
|
|
48
|
+
const rows = changeRows(change, money);
|
|
49
|
+
const order = change.sourceInvoiceRef ? `Order #${change.sourceInvoiceRef}` : null;
|
|
50
|
+
const link = change.link
|
|
51
|
+
? h('a', {
|
|
52
|
+
class: 'fx-btn fx-btn--sm', href: change.link.url, target: '_blank', rel: 'noopener noreferrer',
|
|
53
|
+
title: change.link.exact ? `Opens this job in ${system}.` : `Opens ${system}. Find ${order ?? 'the job'} there.`,
|
|
54
|
+
}, `Open in ${system} ↗`)
|
|
55
|
+
: null;
|
|
56
|
+
const adopt = change.canAdopt && onAdopt
|
|
57
|
+
? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', onclick: onAdopt }, `Adopt ${system} changes`)
|
|
58
|
+
: null;
|
|
59
|
+
const body = h('tbody', {});
|
|
60
|
+
const draw = (all) => body.replaceChildren(...(all ? rows : rows.slice(0, FIRST_ROWS)).map((row) => h('tr', {},
|
|
61
|
+
h('th', { scope: 'row' }, row.field),
|
|
62
|
+
h('td', { class: `fx-change__was${row.num ? ' fx-num' : ''}` }, row.was),
|
|
63
|
+
h('td', { class: `fx-change__now${row.num ? ' fx-num' : ''}` }, row.now))));
|
|
64
|
+
draw(false);
|
|
65
|
+
const more = rows.length > FIRST_ROWS
|
|
66
|
+
? h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: (event) => { draw(true); event.currentTarget.remove(); } }, `Show all ${rows.length}`)
|
|
67
|
+
: null;
|
|
68
|
+
return h('section', { class: 'fx-change', role: 'alert' },
|
|
69
|
+
h('div', { class: 'fx-change__head' },
|
|
70
|
+
h('p', {}, h('strong', {}, 'Problem.'), ' ', changeSummary(change, rows.filter((row) => row.field !== 'Total').length)),
|
|
71
|
+
h('div', { class: 'fx-change__acts' }, link, adopt)),
|
|
72
|
+
adopt ? h('p', { class: 'fx-change__note' }, `Adopt: this invoice takes the Now in ${system} column. Nothing goes to ${system} or the accounting system.`) : null,
|
|
73
|
+
!change.link && order ? h('p', { class: 'fx-change__note' }, `Find ${order} in ${system} to fix it there.`) : null,
|
|
74
|
+
rows.length ? h('div', { class: 'fx-change__scroll' }, h('table', { class: 'fx-change__table' },
|
|
75
|
+
h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, 'Field'), h('th', { scope: 'col' }, 'Was'), h('th', { scope: 'col' }, `Now in ${system}`))),
|
|
76
|
+
body)) : null,
|
|
77
|
+
more);
|
|
78
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// A person's own view of the Jobs page: layout, group, sort and filter. It is
|
|
2
|
+
// saved with their account (PUT /api/me/views/jobs, server/auth/people.js),
|
|
3
|
+
// so it follows them to every device. The rules are here, with no page code,
|
|
4
|
+
// so the server can check a saved view and tests can read them
|
|
5
|
+
// (test/job-view.test.js). server/ui/jobs.js draws the page.
|
|
6
|
+
|
|
7
|
+
export const LAYOUTS = ['board', 'list'];
|
|
8
|
+
export const GROUPS = { status: 'Status', stage: 'Invoice stage', client: 'Client', practitioner: 'Practitioner' };
|
|
9
|
+
export const SORTS = { board: 'Lab system order', due: 'Date', client: 'Client', practitioner: 'Practitioner', total: 'Total' };
|
|
10
|
+
export const DIRS = ['asc', 'desc'];
|
|
11
|
+
export const DEFAULT_VIEW = Object.freeze({ layout: 'board', group: 'status', sort: 'board', dir: 'asc', hide: [], late: false, problems: false });
|
|
12
|
+
|
|
13
|
+
// Group by one of the lab's custom fields (docs/CUSTOM_FIELDS.md):
|
|
14
|
+
// 'field:<field id>'. Only dropdown, person and checkbox fields make lanes.
|
|
15
|
+
const FIELD_GROUP = /^field:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
export const fieldGroup = (fieldId) => `field:${fieldId}`;
|
|
17
|
+
export const fieldIdOf = (group) => (FIELD_GROUP.test(group ?? '') ? group.slice(6) : null);
|
|
18
|
+
|
|
19
|
+
const MAX_HIDDEN = 100;
|
|
20
|
+
const MAX_LABEL = 120;
|
|
21
|
+
|
|
22
|
+
// A view as Flow stores it: only the known keys, each checked. Anything else
|
|
23
|
+
// falls back to the default, so a bad saved view never breaks the page.
|
|
24
|
+
export function normaliseJobsView(input) {
|
|
25
|
+
const value = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
26
|
+
const pick = (key, allowed) => (allowed.includes(value[key]) ? value[key] : DEFAULT_VIEW[key]);
|
|
27
|
+
const hide = Array.isArray(value.hide)
|
|
28
|
+
? [...new Set(value.hide.filter((label) => typeof label === 'string' && label.length > 0 && label.length <= MAX_LABEL))].slice(0, MAX_HIDDEN)
|
|
29
|
+
: [];
|
|
30
|
+
return {
|
|
31
|
+
layout: pick('layout', LAYOUTS),
|
|
32
|
+
group: FIELD_GROUP.test(value.group ?? '') ? value.group : pick('group', Object.keys(GROUPS)),
|
|
33
|
+
sort: pick('sort', Object.keys(SORTS)),
|
|
34
|
+
dir: pick('dir', DIRS),
|
|
35
|
+
hide,
|
|
36
|
+
late: value.late === true,
|
|
37
|
+
problems: value.problems === true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// How many filters are on: shown on the Filter button.
|
|
42
|
+
export function filterCount(view) {
|
|
43
|
+
return view.hide.length + (view.late ? 1 : 0) + (view.problems ? 1 : 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The jobs the filter keeps. `status` is a job's status label; `late` and
|
|
47
|
+
// `problem` say if a job is late or has a problem (jobs.js knows how).
|
|
48
|
+
export function filterJobs(jobs, view, { status, late, problem }) {
|
|
49
|
+
const hidden = new Set(view.hide);
|
|
50
|
+
return jobs.filter((job) => !hidden.has(status(job))
|
|
51
|
+
&& (!view.late || late(job))
|
|
52
|
+
&& (!view.problems || problem(job)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const dayOf = (job) => job.dueOn ?? job.orderedOn ?? job.jobDate ?? null;
|
|
56
|
+
const text = (value) => (value ?? '').toString().toLowerCase();
|
|
57
|
+
const KEYS = {
|
|
58
|
+
due: dayOf,
|
|
59
|
+
client: (job) => text(job.clientName) || null,
|
|
60
|
+
practitioner: (job) => text(job.practitioner) || null,
|
|
61
|
+
total: (job) => job.totalCents ?? null,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// The jobs in the view's order. "Lab system order" keeps the order the API
|
|
65
|
+
// gives. A job with no value for the key goes last, in either direction.
|
|
66
|
+
export function sortJobs(jobs, view) {
|
|
67
|
+
const key = KEYS[view.sort];
|
|
68
|
+
if (!key) return view.dir === 'desc' ? [...jobs].reverse() : [...jobs];
|
|
69
|
+
const sign = view.dir === 'desc' ? -1 : 1;
|
|
70
|
+
return jobs.map((job, i) => [job, key(job), i]).sort(([, a, i], [, b, j]) => {
|
|
71
|
+
if (a == null || b == null) return (a == null) - (b == null) || i - j;
|
|
72
|
+
const order = typeof a === 'number' ? a - b : String(a).localeCompare(String(b));
|
|
73
|
+
return order * sign || i - j;
|
|
74
|
+
}).map(([job]) => job);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The jobs in groups: one board column, or one block of the list, per group,
|
|
78
|
+
// in order. `columns` is the group labels in order (all of them, so an empty
|
|
79
|
+
// status still shows); `labelOf` gives a job's label.
|
|
80
|
+
export function groupJobs(jobs, columns, labelOf) {
|
|
81
|
+
const groups = new Map(columns.map((label) => [label, []]));
|
|
82
|
+
for (const job of jobs) {
|
|
83
|
+
const label = labelOf(job);
|
|
84
|
+
if (!groups.has(label)) groups.set(label, []);
|
|
85
|
+
groups.get(label).push(job);
|
|
86
|
+
}
|
|
87
|
+
return [...groups].map(([label, inGroup]) => ({ label, jobs: inGroup }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The lanes of a custom field, in order: [{ label, value, color }]. `value`
|
|
91
|
+
// is what a card dropped in the lane gets (null clears it). The empty lane
|
|
92
|
+
// comes first, like ClickUp's. An archived option that a job still has gets
|
|
93
|
+
// a lane after the live ones. `people` is [{ id, name }] for a person field;
|
|
94
|
+
// `jobs` are the jobs on the page and `valueOf` reads a job's value.
|
|
95
|
+
export const NOT_SET = 'Not set';
|
|
96
|
+
export function fieldLanes(field, { people = [], jobs = [], valueOf = () => null } = {}) {
|
|
97
|
+
const used = new Set(jobs.map(valueOf).filter((v) => v != null));
|
|
98
|
+
if (field.type === 'checkbox') {
|
|
99
|
+
return [{ label: 'No', value: null, color: 'gray' }, { label: 'Yes', value: true, color: 'green' }];
|
|
100
|
+
}
|
|
101
|
+
const empty = { label: NOT_SET, value: null, color: 'gray' };
|
|
102
|
+
if (field.type === 'person') {
|
|
103
|
+
const known = people.map((p) => ({ label: p.name, value: p.id, color: 'blue' }));
|
|
104
|
+
const gone = [...used].filter((id) => !people.some((p) => p.id === id))
|
|
105
|
+
.map((id, i) => ({ label: `Former person ${i + 1}`, value: id, color: 'gray' }));
|
|
106
|
+
return [empty, ...known, ...gone];
|
|
107
|
+
}
|
|
108
|
+
const options = field.options ?? [];
|
|
109
|
+
return [empty,
|
|
110
|
+
...options.filter((o) => !o.archived).map((o) => ({ label: o.name, value: o.id, color: o.color ?? 'gray' })),
|
|
111
|
+
...options.filter((o) => o.archived && used.has(o.id)).map((o) => ({ label: `${o.name} (archived)`, value: o.id, color: 'gray', archived: true }))];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The lane label a job sits in for a field's lanes. A checkbox that is
|
|
115
|
+
// false sits in "No", with the unset ones.
|
|
116
|
+
export function laneOf(lanes, value) {
|
|
117
|
+
if (value === false || value == null) return lanes[0].label;
|
|
118
|
+
return (lanes.find((lane) => lane.value === value) ?? lanes[0]).label;
|
|
119
|
+
}
|