@arcforgelabs/flow 2026.10.34 → 2026.10.36
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 +2 -1
- package/npm-shrinkwrap.json +81 -50
- package/package.json +7 -5
- package/server/api/custom-fields.js +5 -4
- package/server/api/invoicing.js +54 -12
- package/server/auth/people.js +35 -0
- package/server/auth/routes.js +14 -0
- package/server/core/custom-fields.js +8 -0
- package/server/core/job-board.js +2 -0
- package/server/core/ledger.js +82 -2
- package/server/db/migrations/0019_people_views.sql +1 -0
- package/server/db/migrations/meta/0019_snapshot.json +4295 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +5 -1
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/index.js +17 -5
- package/server/integrations/seazona/settings.js +24 -3
- package/server/integrations/seazona/stage-moves.js +10 -12
- package/server/release-channel.js +8 -6
- package/server/ui/custom-fields.js +223 -56
- package/server/ui/flow.css +155 -2
- package/server/ui/invoice-notes.js +1 -1
- package/server/ui/invoicing.js +160 -59
- 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 +481 -50
- package/server/ui/routes.js +11 -1
package/server/ui/invoicing.js
CHANGED
|
@@ -7,21 +7,22 @@
|
|
|
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
|
-
const STATUS_LABEL = { draft: 'Draft', issued: '
|
|
12
|
+
const STATUS_LABEL = { draft: 'Draft', issued: 'Finalised', void: 'Void' };
|
|
12
13
|
const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
|
|
13
14
|
// Drafts (to check), Problem and Ready are views (server/core/sending.js):
|
|
14
15
|
// every draft is in one of them. A person marks a checked draft ready; a
|
|
15
16
|
// saved change keeps it there. An issued invoice that failed to send is a
|
|
16
17
|
// problem.
|
|
17
18
|
// Ready first: it opens by default, as the drafts most often wait there to send.
|
|
18
|
-
const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', '
|
|
19
|
+
const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Finalised'], ['void', 'Void']];
|
|
19
20
|
const VIEW_LABEL = { problem: 'Problem', ready: 'Ready', sending: 'Sending', to_check: 'Draft' };
|
|
20
21
|
const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_check: 'info' };
|
|
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 };
|
|
@@ -45,6 +46,35 @@ export function xeroTag(ui, status) {
|
|
|
45
46
|
: h('span', { class: 'fx-status', 'data-tone': XERO_TONE[status.state] ?? 'mute' }, status.label);
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// The Linked column: one icon for each connected system (linksTo), with a
|
|
50
|
+
// dot for the state of this invoice's link there. An icon with a link opens
|
|
51
|
+
// the record in that system. Accounting icons show on numbered invoices only.
|
|
52
|
+
const LINK_TONE = {
|
|
53
|
+
not_sent: 'warn', sending: 'warn', not_copied: 'warn', draft: 'success',
|
|
54
|
+
approved: 'success', part_paid: 'success', paid: 'success', voided: 'mute', deleted: 'mute',
|
|
55
|
+
};
|
|
56
|
+
export function linkIcon(ui, system, { tone = null, title, url = null } = {}) {
|
|
57
|
+
const { h } = ui;
|
|
58
|
+
const kids = [h('img', { src: `/assets/ui/logos/${system.id}.svg`, alt: '' }), tone ? h('i', { class: 'fx-link__dot', 'data-tone': tone }) : null];
|
|
59
|
+
const props = { class: `fx-link${tone ? '' : ' fx-link--none'}`, title, 'aria-label': title };
|
|
60
|
+
return url
|
|
61
|
+
? h('a', { ...props, href: url, target: '_blank', rel: 'noopener', onclick: (event) => event.stopPropagation() }, ...kids)
|
|
62
|
+
: h('span', props, ...kids);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function workIcon(ui, system, job) {
|
|
66
|
+
if (job?.sourceSystem !== system.id) return linkIcon(ui, system, { title: `Not linked to ${system.name}` });
|
|
67
|
+
const ref = job.caseRef ?? job.sourceJobRef;
|
|
68
|
+
return linkIcon(ui, system, { tone: 'success', title: `${system.name} ${ref}${job.link?.url ? `. Open in ${system.name}.` : ''}`, url: job.link?.url ?? null });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function accountingIcon(ui, system, status) {
|
|
72
|
+
const tone = status.finding ? 'danger' : LINK_TONE[status.state] ?? 'mute';
|
|
73
|
+
const number = status.xero?.number ? ` ${status.xero.number}` : '';
|
|
74
|
+
const title = `${system.name}${number}: ${status.finding ? status.message : status.label}${status.xero?.url ? ` Open in ${system.name}.` : ''}`;
|
|
75
|
+
return linkIcon(ui, system, { tone, title, url: status.xero?.url ?? null });
|
|
76
|
+
}
|
|
77
|
+
|
|
48
78
|
const showField = (field, value) => (['subtotal', 'tax', 'total'].includes(field) && Number.isFinite(value) ? formatCents(value) : String(value ?? '—'));
|
|
49
79
|
|
|
50
80
|
// Xero's status for some invoices, or an empty map while Xero is off.
|
|
@@ -137,9 +167,9 @@ export async function renderInvoiceList(main, ui) {
|
|
|
137
167
|
const ticked = [...state.ticked];
|
|
138
168
|
const parts = [];
|
|
139
169
|
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
|
|
170
|
+
parts.push(h('p', { class: 'fx-dim' }, ticked.length ? `${plural(ticked.length, 'draft', 'drafts')} ticked.` : 'Check each draft, tick it, then move it to Ready.'));
|
|
141
171
|
if (ticked.length) {
|
|
142
|
-
parts.push(action(`
|
|
172
|
+
parts.push(action(`Move ${ticked.length} to Ready`, 'mark-ready', () => ({ invoiceIds: ticked }), (res) => (res.refused.length
|
|
143
173
|
? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
|
|
144
174
|
: [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
|
|
145
175
|
}
|
|
@@ -151,14 +181,14 @@ export async function renderInvoiceList(main, ui) {
|
|
|
151
181
|
(res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
|
|
152
182
|
}
|
|
153
183
|
if (context.canIssue && body.counts.ready > 0) {
|
|
154
|
-
buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `
|
|
155
|
-
(res) => [`${res.issued}
|
|
184
|
+
buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Finalise all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
|
|
185
|
+
(res) => [`${res.issued} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
|
|
156
186
|
}
|
|
157
187
|
if (buttons.length) parts.push(h('div', { class: 'fx-sendbar__actions' }, buttons));
|
|
158
188
|
} else if (state.status === 'problem') {
|
|
159
189
|
const failed = body.invoices.filter((i) => i.status === 'issued').length;
|
|
160
190
|
if (context.canIssue && to && failed > 0) {
|
|
161
|
-
parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, '
|
|
191
|
+
parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, 'finalised invoice', 'finalised invoices')} did not reach ${to}. Fix the cause, then send again.`));
|
|
162
192
|
parts.push(action('Send again', 'send-again', () => ({}), (res) => [`${res.queued} on the way to ${to} again.`, 'success']));
|
|
163
193
|
}
|
|
164
194
|
}
|
|
@@ -231,8 +261,12 @@ export async function renderInvoiceList(main, ui) {
|
|
|
231
261
|
const showProblem = state.status === 'problem';
|
|
232
262
|
const created = ['to_check', 'problem', 'ready'].includes(state.status);
|
|
233
263
|
// An invoice gets its number when it is sent or issued, so only the
|
|
234
|
-
//
|
|
264
|
+
// Finalised and Void tabs show a Number column; the others open on Client.
|
|
235
265
|
const numbered = !created;
|
|
266
|
+
// With an integration on, every tab has a Linked column (linkIcon).
|
|
267
|
+
const systems = body.linksTo ?? [];
|
|
268
|
+
const linked = systems.length > 0;
|
|
269
|
+
const accountingSystem = systems.find((s) => s.kind === 'accounting') ?? null;
|
|
236
270
|
const boxes = [];
|
|
237
271
|
const tickBox = (invoice) => {
|
|
238
272
|
const box = h('input', {
|
|
@@ -257,7 +291,8 @@ export async function renderInvoiceList(main, ui) {
|
|
|
257
291
|
h('thead', {}, h('tr', {},
|
|
258
292
|
ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
|
|
259
293
|
numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
|
|
260
|
-
h('th', {}, created ? 'Created' : '
|
|
294
|
+
h('th', {}, created ? 'Created' : 'Finalised'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'),
|
|
295
|
+
linked ? h('th', {}, 'Linked') : null)),
|
|
261
296
|
h('tbody', {}, body.invoices.map((invoice) => {
|
|
262
297
|
const href = `/invoices/${invoice.id}`;
|
|
263
298
|
const first = invoice.problems?.[0];
|
|
@@ -275,17 +310,25 @@ export async function renderInvoiceList(main, ui) {
|
|
|
275
310
|
const cell = h('td', { class: 'fx-status-cell' }, view
|
|
276
311
|
? h('span', { class: 'fx-status', 'data-tone': VIEW_TONE[view] }, VIEW_LABEL[view])
|
|
277
312
|
: h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status]));
|
|
313
|
+
if (!linked) statusCells.set(invoice.id, cell);
|
|
314
|
+
return cell;
|
|
315
|
+
})(),
|
|
316
|
+
linked ? (() => {
|
|
317
|
+
const cell = h('td', { class: 'fx-links' }, systems.filter((s) => s.kind === 'work').map((s) => workIcon(ui, s, invoice.job)));
|
|
278
318
|
statusCells.set(invoice.id, cell);
|
|
279
319
|
return cell;
|
|
280
|
-
})());
|
|
320
|
+
})() : null);
|
|
281
321
|
}))));
|
|
282
|
-
//
|
|
283
|
-
|
|
322
|
+
// Finalised and void invoices also show Xero's status, from Flow's copy:
|
|
323
|
+
// in the Linked column every one gets an icon, so one not in Xero
|
|
324
|
+
// stands out.
|
|
325
|
+
if (numbered) {
|
|
284
326
|
const shownBody = body;
|
|
285
327
|
const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, body.invoices.map((i) => i.id));
|
|
286
328
|
if (shownBody !== body) return;
|
|
287
329
|
for (const [invoiceId, status] of statuses) {
|
|
288
|
-
if (
|
|
330
|
+
if (linked) { if (accountingSystem) statusCells.get(invoiceId)?.append(accountingIcon(ui, accountingSystem, status)); }
|
|
331
|
+
else if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
|
|
289
332
|
}
|
|
290
333
|
}
|
|
291
334
|
}
|
|
@@ -305,12 +348,12 @@ function saveNav(nav) {
|
|
|
305
348
|
}
|
|
306
349
|
|
|
307
350
|
// The bar above one invoice: Back to the list as it was, and the previous and
|
|
308
|
-
// next invoice of that list.
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
const
|
|
351
|
+
// next invoice of that list. An invoice opened from elsewhere (the job board,
|
|
352
|
+
// search, a link) steps through its own tab: Drafts, Ready, Problem, Finalised
|
|
353
|
+
// or Void, read once here.
|
|
354
|
+
function navBar(ui, invoice) {
|
|
355
|
+
const { h, go, flow } = ui;
|
|
356
|
+
const id = invoice?.id ?? null;
|
|
314
357
|
const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
|
|
315
358
|
const step = (to, label, path) => {
|
|
316
359
|
const button = h('button', {
|
|
@@ -320,15 +363,33 @@ function navBar(ui, id) {
|
|
|
320
363
|
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>`;
|
|
321
364
|
return button;
|
|
322
365
|
};
|
|
323
|
-
|
|
324
|
-
h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back')
|
|
325
|
-
|
|
326
|
-
at
|
|
366
|
+
const bar = h('nav', { class: 'fx-pager', 'aria-label': 'Invoices' },
|
|
367
|
+
h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back'));
|
|
368
|
+
const fill = (ids) => {
|
|
369
|
+
const at = ids.indexOf(id);
|
|
370
|
+
if (at < 0) return false;
|
|
371
|
+
bar.replaceChildren(bar.firstChild,
|
|
372
|
+
h('span', { class: 'fx-pager__count' }, `${at + 1} of ${ids.length}`),
|
|
373
|
+
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')));
|
|
374
|
+
return true;
|
|
375
|
+
};
|
|
376
|
+
const tab = invoice?.status === 'void' ? 'void' : invoice?.view ?? invoice?.status;
|
|
377
|
+
if (id && !fill(readNav()?.ids ?? []) && TABS.some(([t]) => t === tab)) {
|
|
378
|
+
flow(`/api/invoicing/invoices?${new URLSearchParams({ status: tab })}`).then((body) => {
|
|
379
|
+
const ids = body.invoices.map((i) => i.id);
|
|
380
|
+
if (!fill(ids)) return;
|
|
381
|
+
saveNav({ tab, q: '', ids });
|
|
382
|
+
try { sessionStorage.setItem('flow_invoice_tab', tab); } catch { /* Back opens the last tab */ }
|
|
383
|
+
}).catch(() => { /* Back only */ });
|
|
384
|
+
}
|
|
385
|
+
return bar;
|
|
327
386
|
}
|
|
328
387
|
|
|
329
|
-
// After a send,
|
|
330
|
-
//
|
|
331
|
-
//
|
|
388
|
+
// After a send, a Finalise or a Save as draft, the next invoice of the list
|
|
389
|
+
// opens; at its end, the list. So a person reviews drafts one after another
|
|
390
|
+
// without going back. The invoice stays in the bar's list, so the count holds
|
|
391
|
+
// (2 of 35) and Previous opens it again, now done. Back reloads the list
|
|
392
|
+
// without it.
|
|
332
393
|
export function openNext(ui, id) {
|
|
333
394
|
const ids = readNav()?.ids ?? [];
|
|
334
395
|
const at = ids.indexOf(id);
|
|
@@ -393,7 +454,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
393
454
|
const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
|
|
394
455
|
if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
|
|
395
456
|
let contact = invoice?.contact ?? null;
|
|
396
|
-
//
|
|
457
|
+
// Finalise invoice waits for a client and a due date (syncIssue, below).
|
|
397
458
|
let onContact = () => {};
|
|
398
459
|
|
|
399
460
|
// Client: type to find a customer.
|
|
@@ -545,23 +606,27 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
545
606
|
};
|
|
546
607
|
|
|
547
608
|
// Save shows only when there is something to save: a new invoice, or a
|
|
548
|
-
// change since the page opened.
|
|
609
|
+
// change since the page opened. Save as draft opens the next invoice.
|
|
610
|
+
// Save as draft: a draft that is not ready always offers it. A ready
|
|
611
|
+
// invoice offers Save changes only once something changed.
|
|
612
|
+
const keepsSave = isNew || invoice?.view !== 'ready';
|
|
549
613
|
const saveButton = h('button', {
|
|
550
|
-
class: 'fx-btn', type: 'button', hidden: !
|
|
614
|
+
class: 'fx-btn', type: 'button', hidden: !keepsSave,
|
|
551
615
|
onclick: async () => {
|
|
552
616
|
const saved = await save();
|
|
553
617
|
if (!saved) return;
|
|
554
|
-
toast(
|
|
618
|
+
toast(keepsSave ? 'Saved as a draft.' : 'Changes saved.', 'success');
|
|
555
619
|
if (isNew) go(`/invoices/${saved.id}`, { replace: true });
|
|
620
|
+
else if (keepsSave) openNext(ui, invoice.id);
|
|
556
621
|
else redraw(saved);
|
|
557
622
|
},
|
|
558
|
-
},
|
|
623
|
+
}, keepsSave ? 'Save as draft' : 'Save changes');
|
|
559
624
|
const opened = JSON.stringify(body());
|
|
560
625
|
const changed = () => !isNew && JSON.stringify(body()) !== opened;
|
|
561
|
-
const watchChanges = () => { if (!
|
|
626
|
+
const watchChanges = () => { if (!keepsSave) saveButton.hidden = !changed(); };
|
|
562
627
|
// With an accounting system on, a ready invoice is sent there: Flow issues
|
|
563
628
|
// it and queues it, the same as Send all ready. With none, Flow issues it
|
|
564
|
-
// and keeps it. A draft that is not ready has no send:
|
|
629
|
+
// and keeps it. A draft that is not ready has no send: move it to Ready first.
|
|
565
630
|
const to = invoice?.sendsTo ?? context.sendsTo ?? null;
|
|
566
631
|
const ready = invoice?.view === 'ready';
|
|
567
632
|
const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
|
|
@@ -586,33 +651,36 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
586
651
|
}
|
|
587
652
|
},
|
|
588
653
|
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|
|
654
|
+
// With no accounting system, Finalise invoice gives the draft its number and
|
|
655
|
+
// locks it. A changed job blocks it, as it blocks Move to Ready.
|
|
589
656
|
const issueButton = context.canIssue && !to ? h('button', {
|
|
590
657
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
591
658
|
onclick: async () => {
|
|
592
659
|
const saved = await save();
|
|
593
660
|
if (!saved) return;
|
|
594
661
|
const ok = await ui.dialog({
|
|
595
|
-
title: '
|
|
662
|
+
title: 'Finalise this invoice?',
|
|
596
663
|
body: h('p', {}, `It gets the next number (${nextNumber}). Flow keeps it exactly as issued. To change it later, revise it.`),
|
|
597
|
-
confirm: '
|
|
664
|
+
confirm: 'Finalise',
|
|
598
665
|
});
|
|
599
666
|
if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
|
|
600
667
|
try {
|
|
601
668
|
const { invoice: issued } = await flow(`/api/invoicing/invoices/${saved.id}/issue`, { method: 'POST', body: JSON.stringify({ issueDate: localDate() }) });
|
|
602
|
-
toast(`${issued.number}
|
|
669
|
+
toast(`${issued.number} is final.`, 'success');
|
|
603
670
|
if (isNew) go(`/invoices/${issued.id}`, { replace: true });
|
|
604
|
-
else
|
|
671
|
+
else openNext(ui, invoice.id);
|
|
605
672
|
} catch (error) {
|
|
606
673
|
toast(error.message, 'danger');
|
|
607
674
|
}
|
|
608
675
|
},
|
|
609
|
-
}, '
|
|
676
|
+
}, 'Finalise') : null;
|
|
610
677
|
// Issue needs a client and a due date: until both are set it is greyed.
|
|
678
|
+
// A changed job greys it too, as it does Move to Ready.
|
|
611
679
|
const syncIssue = () => {
|
|
612
680
|
if (!issueButton) return;
|
|
613
681
|
const missing = [contact ? null : 'a client', dueDate.value ? null : 'a due date'].filter(Boolean);
|
|
614
|
-
issueButton.disabled = missing.length > 0;
|
|
615
|
-
issueButton.title = missing.length ? `Choose ${missing.join(' and ')} first.` : '';
|
|
682
|
+
issueButton.disabled = missing.length > 0 || Boolean(invoice?.jobChange);
|
|
683
|
+
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.';
|
|
616
684
|
};
|
|
617
685
|
onContact = syncIssue;
|
|
618
686
|
clientInput.addEventListener('input', syncIssue);
|
|
@@ -630,23 +698,33 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
630
698
|
h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
|
|
631
699
|
h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
|
|
632
700
|
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
701
|
+
// Finalise, with an accounting system on: the draft is saved, then Flow
|
|
702
|
+
// checks it and moves it to the Ready tab, for Send all ready; nothing is
|
|
703
|
+
// sent here. A draft the draft routine made from a job ready to invoice is
|
|
704
|
+
// in Ready already (automations.js), so this is for the rest. One with a
|
|
705
|
+
// problem stays a draft. Move to Drafts, on the Status card, undoes it. A
|
|
706
|
+
// changed job is fixed at its source or adopted, so the button waits.
|
|
707
|
+
const jobChange = invoice?.jobChange ?? null;
|
|
708
|
+
const markReadyButton = isNew || ready || !to ? null : h('button', {
|
|
709
|
+
class: 'fx-btn fx-btn--primary', type: 'button', disabled: Boolean(jobChange),
|
|
710
|
+
title: jobChange ? 'Fix the problem first.' : `Checks this draft and moves it to Ready. Send all ready sends it to ${to}.`,
|
|
637
711
|
onclick: async () => {
|
|
638
712
|
if (!(await save())) return;
|
|
639
713
|
try {
|
|
640
714
|
const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
|
|
641
|
-
if (res.marked)
|
|
642
|
-
|
|
715
|
+
if (res.marked) {
|
|
716
|
+
toast(`Final. It is in Ready; Send all ready sends it to ${to}.`, 'success');
|
|
717
|
+
openNext(ui, invoice.id);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
|
|
643
721
|
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
644
722
|
redraw(fresh);
|
|
645
723
|
} catch (error) {
|
|
646
724
|
toast(error.message, 'danger');
|
|
647
725
|
}
|
|
648
726
|
},
|
|
649
|
-
}, '
|
|
727
|
+
}, 'Finalise');
|
|
650
728
|
// A ready invoice can go back to Drafts. It is a status change, so it sits
|
|
651
729
|
// with the status, not with the page's main buttons.
|
|
652
730
|
const statusCard = ready ? h('section', { class: 'fx-card fx-side-card' },
|
|
@@ -665,18 +743,41 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
665
743
|
}
|
|
666
744
|
},
|
|
667
745
|
}, 'Move to Drafts')) : null;
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
746
|
+
// The job changed after the invoice was made: its own banner shows what
|
|
747
|
+
// changed. Other problems stay one line.
|
|
748
|
+
const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
|
|
749
|
+
const problems = [
|
|
750
|
+
jobChange ? jobChangeBanner(ui, jobChange, {
|
|
751
|
+
money: formatCents,
|
|
752
|
+
// Adopting changes the draft for the business: owners and admins only.
|
|
753
|
+
onAdopt: !context.canIssue ? null : async () => {
|
|
754
|
+
const system = jobChange.systemName;
|
|
755
|
+
const ok = await ui.dialog({
|
|
756
|
+
title: `Adopt the ${system} changes?`,
|
|
757
|
+
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.`),
|
|
758
|
+
confirm: 'Adopt changes',
|
|
759
|
+
});
|
|
760
|
+
if (!ok) return;
|
|
761
|
+
try {
|
|
762
|
+
const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body: '{}' });
|
|
763
|
+
toast(`The invoice now matches ${system}.${res.invoice.view === 'ready' ? ' It is in Ready.' : ''}`, 'success');
|
|
764
|
+
redraw(res.invoice);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
toast(error.message, 'danger');
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
}) : null,
|
|
770
|
+
otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
|
|
771
|
+
];
|
|
671
772
|
|
|
672
773
|
return [
|
|
673
|
-
navBar(ui, invoice
|
|
774
|
+
navBar(ui, invoice),
|
|
674
775
|
h('header', { class: 'fx-ph' },
|
|
675
776
|
h('div', { class: 'fx-ph__text' },
|
|
676
777
|
h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
|
|
677
778
|
h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
|
|
678
|
-
h('div', { class: 'fx-ph__actions' },
|
|
679
|
-
problems,
|
|
779
|
+
h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, markReadyButton, sendButton ?? issueButton)),
|
|
780
|
+
...problems,
|
|
680
781
|
fromJob,
|
|
681
782
|
h('div', { class: 'fx-invoice__grid' },
|
|
682
783
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -767,7 +868,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
767
868
|
const voidBody = () => {
|
|
768
869
|
const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
|
|
769
870
|
return h('div', {},
|
|
770
|
-
h('p', {}, 'The number stays used. A void invoice cannot be
|
|
871
|
+
h('p', {}, 'The number stays used. A void invoice cannot be finalised again.'),
|
|
771
872
|
live ? h('p', { class: 'fx-problem' }, `Xero still holds it as ${xeroStatus.label.replace(/ in Xero$/, '').toLowerCase()}${xeroStatus.xero.number ? ` (${xeroStatus.xero.number})` : ''}. Flow does not change Xero: void it in Xero too.`) : null);
|
|
772
873
|
};
|
|
773
874
|
const actions = invoice.status === 'issued' && context.canIssue ? [
|
|
@@ -778,13 +879,13 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
778
879
|
h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
|
|
779
880
|
] : [];
|
|
780
881
|
return [
|
|
781
|
-
navBar(ui, invoice
|
|
882
|
+
navBar(ui, invoice),
|
|
782
883
|
h('header', { class: 'fx-ph' },
|
|
783
884
|
h('div', { class: 'fx-ph__text' },
|
|
784
885
|
h('div', { class: 'fx-row' },
|
|
785
886
|
h('h1', { class: 'fx-title' }, invoice.number),
|
|
786
887
|
h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])),
|
|
787
|
-
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} ·
|
|
888
|
+
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · finalised ${showDate(invoice.issueDate)}`)),
|
|
788
889
|
h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
|
|
789
890
|
h('div', { class: 'fx-invoice__grid' },
|
|
790
891
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -792,7 +893,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
792
893
|
h('dl', { class: 'fx-doc__meta' },
|
|
793
894
|
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)),
|
|
794
895
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
|
|
795
|
-
h('div', {}, h('dt', { class: 'fx-label' }, '
|
|
896
|
+
h('div', {}, h('dt', { class: 'fx-label' }, 'Finalised'), h('dd', {}, showDate(invoice.issueDate))),
|
|
796
897
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
|
|
797
898
|
h('table', { class: 'fx-lines__table fx-lines__table--read' },
|
|
798
899
|
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'))),
|
|
@@ -809,7 +910,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
809
910
|
invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
|
|
810
911
|
h('h3', { class: 'fx-label' }, 'History'),
|
|
811
912
|
h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
|
|
812
|
-
h('span', { class: 'fx-strong' }, v.version === 1 ? '
|
|
913
|
+
h('span', { class: 'fx-strong' }, v.version === 1 ? 'Finalised' : `Revised (version ${v.version})`),
|
|
813
914
|
h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
|
|
814
915
|
];
|
|
815
916
|
}
|
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
|
+
}
|