@arcforgelabs/flow 2026.10.38 → 2026.10.39
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 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +26 -9
- package/server/api/invoicing.js +149 -51
- package/server/app.js +5 -2
- package/server/core/alerts.js +1 -1
- package/server/core/automations.js +1 -1
- package/server/core/custom-fields.js +74 -6
- package/server/core/ledger.js +29 -7
- package/server/core/price-list.js +27 -2
- package/server/core/read-before-act.js +3 -2
- package/server/core/sending.js +36 -18
- package/server/db/doctor.js +18 -19
- package/server/db/index.js +74 -3
- package/server/db/migrate-all.js +3 -4
- package/server/db/migrations/0020_custom_field_colours.sql +21 -0
- package/server/db/migrations/meta/0020_snapshot.json +4377 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +14 -0
- package/server/integrations/call-guard.js +148 -0
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/index.js +5 -1
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +25 -1
- package/server/integrations/xero/routes.js +24 -7
- package/server/ui/app.js +35 -3
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +185 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +168 -33
- package/server/ui/jobs.js +316 -65
- package/server/ui/price-list.js +43 -86
- package/server/ui/price-match.js +93 -0
- package/server/ui/price-reconcile.js +357 -0
- package/server/ui/reconcile.js +1 -1
- package/server/ui/routes.js +1 -1
package/server/ui/invoicing.js
CHANGED
|
@@ -8,22 +8,22 @@
|
|
|
8
8
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
9
9
|
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
10
10
|
import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
|
|
11
|
+
import { caseNumber } from '/assets/ui/job-board.js';
|
|
11
12
|
import { jobChangeBanner } from '/assets/ui/job-change.js';
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
14
|
+
// The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
|
|
15
|
+
// person must look), Ready (checks pass), then the accounting system's
|
|
16
|
+
// stages: Draft (a draft there, or on its way), Approved, Sent (to the
|
|
17
|
+
// customer). With no accounting system, an issued invoice is Approved.
|
|
18
|
+
const TABS = [['pending', 'Pending'], ['ready', 'Ready'], ['draft', 'Draft'], ['approved', 'Approved'], ['sent', 'Sent'], ['void', 'Void']];
|
|
19
|
+
const TAB_LABEL = Object.fromEntries(TABS);
|
|
20
|
+
const TAB_TONE = { pending: 'danger', ready: 'success', draft: 'info', approved: 'info', sent: 'success', void: 'mute' };
|
|
21
|
+
// Tab names an earlier Flow kept in this browser.
|
|
22
|
+
const OLD_TAB = { problem: 'pending', to_check: 'ready', issued: 'approved' };
|
|
23
|
+
const NUMBERED = ['draft', 'approved', 'sent', 'void'];
|
|
23
24
|
const EMPTY = {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
ready: 'Nothing is ready. In Drafts, tick drafts and press Ready.',
|
|
25
|
+
pending: 'Nothing needs a look.',
|
|
26
|
+
ready: 'Nothing is ready. Press New invoice to start one.',
|
|
27
27
|
};
|
|
28
28
|
// Every tab's rows can be ticked; the bulk bar (bulkActions) says what the
|
|
29
29
|
// ticked rows can do there.
|
|
@@ -111,7 +111,7 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
|
|
|
111
111
|
|
|
112
112
|
export async function renderInvoiceList(main, ui) {
|
|
113
113
|
const { h, flow, pageHeader, notice, go, toast } = ui;
|
|
114
|
-
const saved = sessionStorage.getItem('flow_invoice_tab');
|
|
114
|
+
const saved = OLD_TAB[sessionStorage.getItem('flow_invoice_tab')] ?? sessionStorage.getItem('flow_invoice_tab');
|
|
115
115
|
// Back from an invoice comes here with the tab, search and scroll it left.
|
|
116
116
|
const back = readNav();
|
|
117
117
|
const returning = sessionStorage.getItem(NAV_BACK) === '1';
|
|
@@ -177,20 +177,18 @@ export async function renderInvoiceList(main, ui) {
|
|
|
177
177
|
const to = body.sendsTo;
|
|
178
178
|
const ticked = [...state.ticked];
|
|
179
179
|
const parts = [];
|
|
180
|
-
if (state.status === '
|
|
181
|
-
parts.push(h('p', { class: 'fx-dim' }, 'Check each draft, tick it, then press Ready.'));
|
|
182
|
-
} else if (state.status === 'ready') {
|
|
180
|
+
if (state.status === 'ready') {
|
|
183
181
|
parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
|
|
184
182
|
const buttons = [];
|
|
185
183
|
if (context.canIssue && body.counts.ready > 0) {
|
|
186
|
-
buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `
|
|
187
|
-
(res) => [`${res.issued}
|
|
184
|
+
buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Approve all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
|
|
185
|
+
(res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
|
|
188
186
|
}
|
|
189
187
|
if (buttons.length) parts.push(h('div', { class: 'fx-sendbar__actions' }, buttons));
|
|
190
|
-
} else if (state.status === '
|
|
191
|
-
const failed = body.invoices.filter((i) => i.status === 'issued').length;
|
|
188
|
+
} else if (state.status === 'pending') {
|
|
189
|
+
const failed = body.invoices.filter((i) => i.status === 'issued' && i.problems.some((p) => p.code === 'send_failed' || p.code?.startsWith('xero_'))).length;
|
|
192
190
|
if (context.canIssue && to && failed > 0) {
|
|
193
|
-
parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, '
|
|
191
|
+
parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, 'invoice', 'invoices')} did not reach ${to}. Fix the cause, then send again.`));
|
|
194
192
|
parts.push(action('Send again', 'send-again', () => ({}), (res) => [`${res.queued} on the way to ${to} again.`, 'success']));
|
|
195
193
|
}
|
|
196
194
|
}
|
|
@@ -225,41 +223,27 @@ export async function renderInvoiceList(main, ui) {
|
|
|
225
223
|
function bulkActions(ticked) {
|
|
226
224
|
const to = body.sendsTo;
|
|
227
225
|
const rows = body.invoices.filter((i) => state.ticked.has(i.id));
|
|
228
|
-
const numbered =
|
|
226
|
+
const numbered = NUMBERED.includes(state.status);
|
|
229
227
|
const status = [];
|
|
230
228
|
const apply = [];
|
|
231
229
|
if (state.status === 'ready' && context.canIssue) {
|
|
232
230
|
status.push({
|
|
233
|
-
icon: 'circle-check-big', label: '
|
|
234
|
-
title: to ? `
|
|
231
|
+
icon: to ? 'send' : 'circle-check-big', label: to ? 'Send' : 'Approve', primary: true,
|
|
232
|
+
title: to ? `Send to ${to}` : 'Approve',
|
|
235
233
|
run: async () => {
|
|
236
234
|
const ok = await ui.dialog({
|
|
237
|
-
title: `
|
|
238
|
-
body: h('p', {},
|
|
239
|
-
confirm: to ? `
|
|
235
|
+
title: to ? `Send ${plural(ticked.length, 'invoice', 'invoices')} to ${to}?` : `Approve ${plural(ticked.length, 'invoice', 'invoices')}?`,
|
|
236
|
+
body: h('p', {}, to ? sendModeNote(to, body.sendMode, 'each one') : 'Each one gets the next number and is locked.'),
|
|
237
|
+
confirm: to ? `Send ${ticked.length}` : `Approve ${ticked.length}`,
|
|
240
238
|
});
|
|
241
239
|
if (!ok) return;
|
|
242
240
|
await post('send-ready', { issueDate: localDate(), invoiceIds: ticked },
|
|
243
|
-
(res) => [`${
|
|
241
|
+
(res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, 'success']);
|
|
244
242
|
},
|
|
245
243
|
});
|
|
246
244
|
}
|
|
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
245
|
const failed = rows.filter((i) => i.status === 'issued').map((i) => i.id);
|
|
262
|
-
if (state.status === '
|
|
246
|
+
if (state.status === 'pending' && context.canIssue && to && failed.length) {
|
|
263
247
|
status.push({
|
|
264
248
|
icon: 'refresh-cw', label: 'Retry', primary: true, title: `Send again to ${to}`,
|
|
265
249
|
run: () => post('send-again', { invoiceIds: failed }, (res) => [`${res.queued} on the way to ${to} again.`, 'success']),
|
|
@@ -273,7 +257,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
273
257
|
if (numbered) apply.push({ icon: 'file-down', label: 'PDFs', iconOnly: true, title: 'Download PDFs', run: () => downloadPdfs(rows) });
|
|
274
258
|
const danger = [];
|
|
275
259
|
const voidable = rows.filter((i) => i.status === 'issued');
|
|
276
|
-
if (
|
|
260
|
+
if (['draft', 'approved', 'sent'].includes(state.status) && context.canIssue && voidable.length) {
|
|
277
261
|
danger.push({
|
|
278
262
|
icon: 'ban', label: 'Void', iconOnly: true, tone: 'danger', title: 'Void',
|
|
279
263
|
run: () => voidAll(voidable),
|
|
@@ -309,7 +293,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
309
293
|
const cell = (v) => (/[",\n]/.test(String(v ?? '')) ? `"${String(v).replace(/"/g, '""')}"` : String(v ?? ''));
|
|
310
294
|
const lines = [['Number', 'Client', 'Reference', 'Date', 'Total', 'Status']]
|
|
311
295
|
.concat(rows.map((i) => [i.number ?? '', i.contactName ?? '', i.reference ?? '', i.issueDate ?? i.createdAt?.slice(0, 10) ?? '',
|
|
312
|
-
((i.totalCents ?? 0) / 100).toFixed(2),
|
|
296
|
+
((i.totalCents ?? 0) / 100).toFixed(2), TAB_LABEL[i.tab] ?? '']));
|
|
313
297
|
save(new Blob([lines.map((l) => l.map(cell).join(',')).join('\r\n')], { type: 'text/csv' }), `invoices-${state.status}-${localDate()}.csv`);
|
|
314
298
|
toast(`${plural(rows.length, 'invoice', 'invoices')} exported.`, 'success');
|
|
315
299
|
}
|
|
@@ -334,7 +318,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
334
318
|
async function voidAll(rows) {
|
|
335
319
|
const ok = await ui.dialog({
|
|
336
320
|
title: `Void ${plural(rows.length, 'invoice', 'invoices')}?`,
|
|
337
|
-
body: h('div', {}, h('p', {}, 'Their numbers stay used. A void invoice cannot be
|
|
321
|
+
body: h('div', {}, h('p', {}, 'Their numbers stay used. A void invoice cannot be used again.'),
|
|
338
322
|
body.sendsTo ? h('p', { class: 'fx-problem' }, `Flow does not change ${body.sendsTo}: void them in ${body.sendsTo} too.`) : null),
|
|
339
323
|
confirm: `Void ${rows.length}`, tone: 'danger',
|
|
340
324
|
});
|
|
@@ -394,7 +378,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
394
378
|
type: 'button',
|
|
395
379
|
role: 'tab',
|
|
396
380
|
'aria-selected': String(state.status === id),
|
|
397
|
-
...(id === '
|
|
381
|
+
...(id === 'pending' && counts?.pending ? { 'data-tone': 'danger' } : {}),
|
|
398
382
|
onclick: () => { if (state.status !== id) openTab(id); },
|
|
399
383
|
}, label, h('span', { class: 'fx-tabs__count' }, String(counts?.[id] ?? 0)))));
|
|
400
384
|
}
|
|
@@ -408,17 +392,17 @@ export async function renderInvoiceList(main, ui) {
|
|
|
408
392
|
if (!body.invoices.length) {
|
|
409
393
|
host.replaceChildren(h('p', { class: 'fx-empty' }, state.q
|
|
410
394
|
? 'No invoice matches that search.'
|
|
411
|
-
: EMPTY[state.status] ?? `No ${
|
|
395
|
+
: EMPTY[state.status] ?? `No ${TAB_LABEL[state.status].toLowerCase()} invoices.`));
|
|
412
396
|
return;
|
|
413
397
|
}
|
|
414
398
|
// The invoice page steps through this list, in this order (navBar).
|
|
415
399
|
saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
|
|
416
400
|
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
|
|
417
401
|
const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
|
|
418
|
-
const showProblem = state.status === '
|
|
419
|
-
const created = ['
|
|
402
|
+
const showProblem = state.status === 'pending';
|
|
403
|
+
const created = ['pending', 'ready'].includes(state.status);
|
|
420
404
|
// An invoice gets its number when it is sent or issued, so only the
|
|
421
|
-
//
|
|
405
|
+
// Numbered tabs show a Number column; the others open on Client.
|
|
422
406
|
const numbered = !created;
|
|
423
407
|
// With an integration on, every tab has a Linked column (linkIcon).
|
|
424
408
|
const systems = body.linksTo ?? [];
|
|
@@ -449,25 +433,24 @@ export async function renderInvoiceList(main, ui) {
|
|
|
449
433
|
h('thead', {}, h('tr', {},
|
|
450
434
|
h('th', { class: 'fx-tick-cell' }, all),
|
|
451
435
|
numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
|
|
452
|
-
h('th', {}, created ? 'Created' : '
|
|
436
|
+
h('th', {}, created ? 'Created' : 'Date'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'),
|
|
453
437
|
linked ? h('th', {}, 'Linked') : null)),
|
|
454
438
|
h('tbody', {}, body.invoices.map((invoice) => {
|
|
455
439
|
const href = `/invoices/${invoice.id}`;
|
|
456
440
|
const first = invoice.problems?.[0];
|
|
457
|
-
const view = invoice.status === 'void' ? null : invoice.view;
|
|
458
441
|
return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
|
|
459
442
|
h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)),
|
|
460
443
|
numbered ? h('td', {}, link(href, invoice.number ?? 'Draft')) : null,
|
|
461
444
|
h('td', {}, numbered ? (invoice.contactName ?? '—') : link(href, invoice.contactName ?? 'No client')),
|
|
462
445
|
showProblem
|
|
463
|
-
? h('td', { class: 'fx-problem' }, first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1}
|
|
446
|
+
? h('td', { class: 'fx-problem', title: invoice.problems.map((p) => p.message).join(' ') }, first?.short ?? first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1}`) : null)
|
|
464
447
|
: h('td', { class: 'fx-dim' }, invoice.reference ?? '—'),
|
|
465
448
|
h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
|
|
466
449
|
h('td', { class: 'fx-num fx-strong' }, formatCents(invoice.totalCents)),
|
|
467
450
|
(() => {
|
|
468
|
-
const cell = h('td', { class: 'fx-status-cell' },
|
|
469
|
-
? h('span', { class: 'fx-status', 'data-tone':
|
|
470
|
-
: h('span', { class: 'fx-status', 'data-tone':
|
|
451
|
+
const cell = h('td', { class: 'fx-status-cell' }, invoice.sending
|
|
452
|
+
? h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Sending')
|
|
453
|
+
: h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute' }, TAB_LABEL[invoice.tab] ?? ''));
|
|
471
454
|
if (!linked) statusCells.set(invoice.id, cell);
|
|
472
455
|
return cell;
|
|
473
456
|
})(),
|
|
@@ -477,7 +460,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
477
460
|
return cell;
|
|
478
461
|
})() : null);
|
|
479
462
|
}))));
|
|
480
|
-
//
|
|
463
|
+
// Issued and void invoices also show Xero's status, from Flow's copy:
|
|
481
464
|
// in the Linked column every one gets an icon, so one not in Xero
|
|
482
465
|
// stands out.
|
|
483
466
|
if (numbered) {
|
|
@@ -519,8 +502,7 @@ function saveNav(nav) {
|
|
|
519
502
|
|
|
520
503
|
// The bar above one invoice: Back to the list as it was, and the previous and
|
|
521
504
|
// next invoice of that list. An invoice opened from elsewhere (the job board,
|
|
522
|
-
// search, a link) steps through its own tab
|
|
523
|
-
// or Void, read once here.
|
|
505
|
+
// search, a link) steps through its own tab, read once here.
|
|
524
506
|
function navBar(ui, invoice) {
|
|
525
507
|
const { h, go, flow } = ui;
|
|
526
508
|
const id = invoice?.id ?? null;
|
|
@@ -543,7 +525,7 @@ function navBar(ui, invoice) {
|
|
|
543
525
|
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')));
|
|
544
526
|
return true;
|
|
545
527
|
};
|
|
546
|
-
const tab = invoice?.
|
|
528
|
+
const tab = invoice?.tab ?? null;
|
|
547
529
|
if (id && !fill(readNav()?.ids ?? []) && TABS.some(([t]) => t === tab)) {
|
|
548
530
|
flow(`/api/invoicing/invoices?${new URLSearchParams({ status: tab })}`).then((body) => {
|
|
549
531
|
const ids = body.invoices.map((i) => i.id);
|
|
@@ -555,7 +537,7 @@ function navBar(ui, invoice) {
|
|
|
555
537
|
return bar;
|
|
556
538
|
}
|
|
557
539
|
|
|
558
|
-
// After a send,
|
|
540
|
+
// After a send, an Approve or a Save as draft, the next invoice of the list
|
|
559
541
|
// opens; at its end, the list. So a person reviews drafts one after another
|
|
560
542
|
// without going back. The invoice stays in the bar's list, so the count holds
|
|
561
543
|
// (2 of 35) and Previous opens it again, now done. Back reloads the list
|
|
@@ -605,6 +587,10 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
|
|
|
605
587
|
h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
|
|
606
588
|
}
|
|
607
589
|
|
|
590
|
+
// The job's number a person reads: its case number, else its order number.
|
|
591
|
+
// A system's own record id (a UUID) is never shown (job-board.js).
|
|
592
|
+
const jobRef = (job) => caseNumber(job) ?? (job.sourceInvoiceRef ? `#${job.sourceInvoiceRef}` : null);
|
|
593
|
+
|
|
608
594
|
// The Linked card: this invoice's link in each connected system (linksTo).
|
|
609
595
|
// A work system row names the job's order and opens it there; the
|
|
610
596
|
// accounting row gives the number and state there (from Flow's copy). A
|
|
@@ -628,7 +614,7 @@ function linkedCard(ui, invoice, accountingStatus = null) {
|
|
|
628
614
|
const rows = systems.map((system) => {
|
|
629
615
|
if (system.kind === 'work') {
|
|
630
616
|
return job?.sourceSystem === system.id
|
|
631
|
-
? row(system, { tone: 'success', ref: job
|
|
617
|
+
? row(system, { tone: 'success', ref: jobRef(job), url: job.link?.url ?? null })
|
|
632
618
|
: row(system, { none: 'No job linked' });
|
|
633
619
|
}
|
|
634
620
|
const status = accountingStatus;
|
|
@@ -654,7 +640,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
654
640
|
const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
|
|
655
641
|
if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
|
|
656
642
|
let contact = invoice?.contact ?? null;
|
|
657
|
-
//
|
|
643
|
+
// Approve waits for a client and a due date (syncIssue, below).
|
|
658
644
|
let onContact = () => {};
|
|
659
645
|
|
|
660
646
|
// Client: type to find a customer.
|
|
@@ -826,7 +812,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
826
812
|
const watchChanges = () => { if (!keepsSave) saveButton.hidden = !changed(); };
|
|
827
813
|
// With an accounting system on, a ready invoice is sent there: Flow issues
|
|
828
814
|
// it and queues it, the same as Send all ready. With none, Flow issues it
|
|
829
|
-
// and keeps it. A draft
|
|
815
|
+
// and keeps it. A Pending draft has no send: fix its problem first.
|
|
830
816
|
const to = invoice?.sendsTo ?? context.sendsTo ?? null;
|
|
831
817
|
const ready = invoice?.view === 'ready';
|
|
832
818
|
const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
|
|
@@ -851,31 +837,31 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
851
837
|
}
|
|
852
838
|
},
|
|
853
839
|
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|
|
854
|
-
// With no accounting system,
|
|
855
|
-
//
|
|
840
|
+
// With no accounting system, Approve gives the draft its number and locks
|
|
841
|
+
// it. A changed job blocks it.
|
|
856
842
|
const issueButton = context.canIssue && !to ? h('button', {
|
|
857
843
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
858
844
|
onclick: async () => {
|
|
859
845
|
const saved = await save();
|
|
860
846
|
if (!saved) return;
|
|
861
847
|
const ok = await ui.dialog({
|
|
862
|
-
title: '
|
|
848
|
+
title: 'Approve this invoice?',
|
|
863
849
|
body: h('p', {}, `It gets the next number (${nextNumber}). Flow keeps it exactly as issued. To change it later, revise it.`),
|
|
864
|
-
confirm: '
|
|
850
|
+
confirm: 'Approve',
|
|
865
851
|
});
|
|
866
852
|
if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
|
|
867
853
|
try {
|
|
868
854
|
const { invoice: issued } = await flow(`/api/invoicing/invoices/${saved.id}/issue`, { method: 'POST', body: JSON.stringify({ issueDate: localDate() }) });
|
|
869
|
-
toast(`${issued.number}
|
|
855
|
+
toast(`${issued.number} approved.`, 'success');
|
|
870
856
|
if (isNew) go(`/invoices/${issued.id}`, { replace: true });
|
|
871
857
|
else openNext(ui, invoice.id);
|
|
872
858
|
} catch (error) {
|
|
873
859
|
toast(error.message, 'danger');
|
|
874
860
|
}
|
|
875
861
|
},
|
|
876
|
-
}, '
|
|
862
|
+
}, 'Approve') : null;
|
|
877
863
|
// Issue needs a client and a due date: until both are set it is greyed.
|
|
878
|
-
// A changed job greys it too
|
|
864
|
+
// A changed job greys it too.
|
|
879
865
|
const syncIssue = () => {
|
|
880
866
|
if (!issueButton) return;
|
|
881
867
|
const missing = [contact ? null : 'a client', dueDate.value ? null : 'a due date'].filter(Boolean);
|
|
@@ -898,74 +884,41 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
898
884
|
h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
|
|
899
885
|
h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
|
|
900
886
|
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
// sent here. A draft the draft routine made from a job ready to invoice is
|
|
904
|
-
// in Ready already (automations.js), so this is for the rest. One with a
|
|
905
|
-
// problem stays a draft. Move to Drafts, on the Status card, undoes it. A
|
|
906
|
-
// changed job is fixed at its source or adopted, so the button waits.
|
|
887
|
+
// A draft with no problem is in Ready by itself; one with a problem is
|
|
888
|
+
// Pending. A changed job is fixed at its source or accepted here.
|
|
907
889
|
const jobChange = invoice?.jobChange ?? null;
|
|
908
|
-
const markReadyButton = isNew || ready || !to ? null : h('button', {
|
|
909
|
-
class: 'fx-btn fx-btn--primary', type: 'button', disabled: Boolean(jobChange),
|
|
910
|
-
title: jobChange ? 'Fix the problem first.' : `Checks this draft and moves it to Ready. Send all ready sends it to ${to}.`,
|
|
911
|
-
onclick: async () => {
|
|
912
|
-
if (!(await save())) return;
|
|
913
|
-
try {
|
|
914
|
-
const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
|
|
915
|
-
if (res.marked) {
|
|
916
|
-
toast(`Final. It is in Ready; Send all ready sends it to ${to}.`, 'success');
|
|
917
|
-
openNext(ui, invoice.id);
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
920
|
-
toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
|
|
921
|
-
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
922
|
-
redraw(fresh);
|
|
923
|
-
} catch (error) {
|
|
924
|
-
toast(error.message, 'danger');
|
|
925
|
-
}
|
|
926
|
-
},
|
|
927
|
-
}, 'Finalise');
|
|
928
|
-
// A ready invoice can go back to Drafts. It is a status change, so it sits
|
|
929
|
-
// with the status, not with the page's main buttons.
|
|
930
|
-
const statusCard = ready ? h('section', { class: 'fx-card fx-side-card' },
|
|
931
|
-
h('h3', { class: 'fx-label' }, 'Status'),
|
|
932
|
-
h('p', {}, 'Ready to send.'),
|
|
933
|
-
h('button', {
|
|
934
|
-
class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button',
|
|
935
|
-
onclick: async () => {
|
|
936
|
-
try {
|
|
937
|
-
await flow('/api/invoicing/invoices/mark-not-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
|
|
938
|
-
toast('Moved to Drafts.', 'success');
|
|
939
|
-
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
940
|
-
redraw(fresh);
|
|
941
|
-
} catch (error) {
|
|
942
|
-
toast(error.message, 'danger');
|
|
943
|
-
}
|
|
944
|
-
},
|
|
945
|
-
}, 'Move to Drafts')) : null;
|
|
946
890
|
// The job changed after the invoice was made: its own banner shows what
|
|
947
891
|
// changed. Other problems stay one line.
|
|
948
892
|
const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
|
|
949
893
|
const problems = [
|
|
950
894
|
jobChange ? jobChangeBanner(ui, jobChange, {
|
|
951
895
|
money: formatCents,
|
|
952
|
-
//
|
|
953
|
-
|
|
896
|
+
// Accepting changes the draft for the business: owners and admins only.
|
|
897
|
+
onAccept: !context.canIssue ? null : async () => {
|
|
954
898
|
const system = jobChange.systemName;
|
|
955
|
-
const
|
|
956
|
-
|
|
957
|
-
body: h('p', {},
|
|
958
|
-
|
|
959
|
-
}
|
|
960
|
-
if (!ok) return;
|
|
899
|
+
const lost = [jobChange.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, changed() ? 'Unsaved changes are lost.' : null].filter(Boolean);
|
|
900
|
+
if (lost.length) {
|
|
901
|
+
const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
|
|
902
|
+
if (!ok) return;
|
|
903
|
+
}
|
|
961
904
|
try {
|
|
962
|
-
const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body:
|
|
963
|
-
toast(`
|
|
905
|
+
const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
|
|
906
|
+
toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
|
|
964
907
|
redraw(res.invoice);
|
|
965
908
|
} catch (error) {
|
|
966
909
|
toast(error.message, 'danger');
|
|
967
910
|
}
|
|
968
911
|
},
|
|
912
|
+
// Reads this one job from the work system again, then every check runs.
|
|
913
|
+
onRecheck: async () => {
|
|
914
|
+
try {
|
|
915
|
+
const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/recheck`, { method: 'POST', body: '{}' });
|
|
916
|
+
toast(res.invoice?.jobChange ? `Still changed in ${jobChange.systemName}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
|
|
917
|
+
if (res.invoice) redraw(res.invoice);
|
|
918
|
+
} catch (error) {
|
|
919
|
+
toast(error.message, 'danger');
|
|
920
|
+
}
|
|
921
|
+
},
|
|
969
922
|
}) : null,
|
|
970
923
|
otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
|
|
971
924
|
];
|
|
@@ -974,9 +927,9 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
974
927
|
navBar(ui, invoice),
|
|
975
928
|
h('header', { class: 'fx-ph' },
|
|
976
929
|
h('div', { class: 'fx-ph__text' },
|
|
977
|
-
h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : '
|
|
930
|
+
h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice'),
|
|
978
931
|
h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
|
|
979
|
-
h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton,
|
|
932
|
+
h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton)),
|
|
980
933
|
...problems,
|
|
981
934
|
fromJob,
|
|
982
935
|
h('div', { class: 'fx-invoice__grid' },
|
|
@@ -996,7 +949,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
996
949
|
h('div', { class: 'fx-lines__foot' },
|
|
997
950
|
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'),
|
|
998
951
|
totalsHost))),
|
|
999
|
-
h('aside', { class: 'fx-invoice__aside' },
|
|
952
|
+
h('aside', { class: 'fx-invoice__aside' }, linkedCard(ui, invoice))),
|
|
1000
953
|
].map((part) => {
|
|
1001
954
|
// Any edit on the page shows Save changes; undoing it hides it again.
|
|
1002
955
|
for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
|
|
@@ -1070,7 +1023,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1070
1023
|
const voidBody = () => {
|
|
1071
1024
|
const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
|
|
1072
1025
|
return h('div', {},
|
|
1073
|
-
h('p', {}, 'The number stays used. A void invoice cannot be
|
|
1026
|
+
h('p', {}, 'The number stays used. A void invoice cannot be used again.'),
|
|
1074
1027
|
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);
|
|
1075
1028
|
};
|
|
1076
1029
|
const actions = invoice.status === 'issued' && context.canIssue ? [
|
|
@@ -1086,8 +1039,8 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1086
1039
|
h('div', { class: 'fx-ph__text' },
|
|
1087
1040
|
h('div', { class: 'fx-row' },
|
|
1088
1041
|
h('h1', { class: 'fx-title' }, invoice.number),
|
|
1089
|
-
h('span', { class: 'fx-status', 'data-tone':
|
|
1090
|
-
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} ·
|
|
1042
|
+
h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
|
|
1043
|
+
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · ${showDate(invoice.issueDate)}`)),
|
|
1091
1044
|
h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
|
|
1092
1045
|
h('div', { class: 'fx-invoice__grid' },
|
|
1093
1046
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -1095,7 +1048,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1095
1048
|
h('dl', { class: 'fx-doc__meta' },
|
|
1096
1049
|
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)),
|
|
1097
1050
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
|
|
1098
|
-
h('div', {}, h('dt', { class: 'fx-label' }, '
|
|
1051
|
+
h('div', {}, h('dt', { class: 'fx-label' }, 'Date'), h('dd', {}, showDate(invoice.issueDate))),
|
|
1099
1052
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
|
|
1100
1053
|
h('table', { class: 'fx-lines__table fx-lines__table--read' },
|
|
1101
1054
|
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'))),
|
|
@@ -1112,7 +1065,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1112
1065
|
invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
|
|
1113
1066
|
h('h3', { class: 'fx-label' }, 'History'),
|
|
1114
1067
|
h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
|
|
1115
|
-
h('span', { class: 'fx-strong' }, v.version === 1 ? '
|
|
1068
|
+
h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
|
|
1116
1069
|
h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
|
|
1117
1070
|
];
|
|
1118
1071
|
}
|
|
@@ -1138,8 +1091,9 @@ function xeroCard(invoice, ui, context, status, reload) {
|
|
|
1138
1091
|
toast(res.resolved ? 'Flow and Xero agree now.' : 'Still different. Fix it in Xero or in Flow, then try again.', res.resolved ? 'success' : 'danger');
|
|
1139
1092
|
await reload();
|
|
1140
1093
|
} catch (error) {
|
|
1141
|
-
toast(error.message, 'danger');
|
|
1094
|
+
toast(error.message, error.status === 429 ? 'info' : 'danger');
|
|
1142
1095
|
resolve.disabled = false;
|
|
1096
|
+
ui.holdButton(resolve, error.retryAfterMs);
|
|
1143
1097
|
}
|
|
1144
1098
|
},
|
|
1145
1099
|
}, 'Mark resolved') : null;
|
|
@@ -1179,9 +1133,10 @@ function sendToXero(invoice, ui, context) {
|
|
|
1179
1133
|
sent = ['created', 'reconciled', 'existing', 'already_in_xero'].includes(result?.outcome);
|
|
1180
1134
|
} catch (error) {
|
|
1181
1135
|
toast(error.message, 'danger');
|
|
1136
|
+
if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
|
|
1182
1137
|
} finally {
|
|
1183
1138
|
if (sent) showSent(button);
|
|
1184
|
-
else button.disabled = false;
|
|
1139
|
+
else if (!button.dataset.label) button.disabled = false;
|
|
1185
1140
|
}
|
|
1186
1141
|
},
|
|
1187
1142
|
}, 'Send to Xero');
|
package/server/ui/job-board.js
CHANGED
|
@@ -42,7 +42,7 @@ export function orderStatuses(statuses = [], jobs = []) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export const INVOICE_STAGES = {
|
|
45
|
-
drafted: 'Drafted', problem: '
|
|
45
|
+
drafted: 'Drafted', problem: 'Pending', ready: 'Ready to invoice', invoiced: 'Invoiced', paid: 'Paid',
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
const PROBLEM_VIEWS = ['pending', 'review_required', 'held'];
|
package/server/ui/job-change.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The "job changed" problem on a draft invoice (server/core/ledger.js,
|
|
2
2
|
// jobChangeFor): what the job was when the invoice was made and what the
|
|
3
3
|
// work system has now, one row per changed fact, with "Open in <system>" to
|
|
4
|
-
// fix it there
|
|
5
|
-
//
|
|
4
|
+
// fix it there, "Accept change" to take the new values into Flow's own draft,
|
|
5
|
+
// and a recheck button that reads the job from the work system again. Flow
|
|
6
|
+
// never writes to the work system. Kept apart from
|
|
6
7
|
// invoicing.js so tests can load it without a browser.
|
|
7
8
|
|
|
8
9
|
// Rows shown before "Show all".
|
|
@@ -35,14 +36,14 @@ export function changeRows(change, money) {
|
|
|
35
36
|
export function changeSummary(change, rowCount) {
|
|
36
37
|
const system = change.systemName ?? 'the work system';
|
|
37
38
|
const count = `${rowCount} ${rowCount === 1 ? 'change' : 'changes'}.`;
|
|
38
|
-
if (change.canAdopt) return `
|
|
39
|
-
if (change.sent) return `
|
|
40
|
-
return `
|
|
39
|
+
if (change.canAdopt) return `Changed in ${system}. ${count}`;
|
|
40
|
+
if (change.sent) return `Changed in ${system} after sending. ${count}`;
|
|
41
|
+
return `Changed in ${system} and here. ${count}`;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
// The banner. `
|
|
44
|
-
//
|
|
45
|
-
export function jobChangeBanner(ui, change, { money,
|
|
44
|
+
// The banner. `onAccept` runs "Accept change"; it is shown while the draft
|
|
45
|
+
// is not sent (ledger.js, canAccept). `onRecheck` reads the job again.
|
|
46
|
+
export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck = null }) {
|
|
46
47
|
const { h } = ui;
|
|
47
48
|
const system = change.systemName ?? 'the work system';
|
|
48
49
|
const rows = changeRows(change, money);
|
|
@@ -53,8 +54,19 @@ export function jobChangeBanner(ui, change, { money, onAdopt = null }) {
|
|
|
53
54
|
title: change.link.exact ? `Opens this job in ${system}.` : `Opens ${system}. Find ${order ?? 'the job'} there.`,
|
|
54
55
|
}, `Open in ${system} ↗`)
|
|
55
56
|
: null;
|
|
56
|
-
const
|
|
57
|
-
? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button',
|
|
57
|
+
const accept = change.canAccept && onAccept
|
|
58
|
+
? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', title: `This invoice takes the Now in ${system} column.`, onclick: onAccept }, 'Accept change')
|
|
59
|
+
: null;
|
|
60
|
+
const recheck = onRecheck
|
|
61
|
+
? h('button', {
|
|
62
|
+
class: 'fx-btn fx-btn--sm fx-btn--icon fx-change__recheck', type: 'button', title: `Check ${system} again`, 'aria-label': `Check ${system} again`,
|
|
63
|
+
onclick: async (event) => {
|
|
64
|
+
const button = event.currentTarget;
|
|
65
|
+
button.disabled = true;
|
|
66
|
+
button.classList.add('is-busy');
|
|
67
|
+
try { await onRecheck(); } finally { button.disabled = false; button.classList.remove('is-busy'); }
|
|
68
|
+
},
|
|
69
|
+
}, h('span', { class: 'fx-lucide', style: '--icon: url(/assets/icons/refresh-cw.svg)', 'aria-hidden': 'true' }))
|
|
58
70
|
: null;
|
|
59
71
|
const body = h('tbody', {});
|
|
60
72
|
const draw = (all) => body.replaceChildren(...(all ? rows : rows.slice(0, FIRST_ROWS)).map((row) => h('tr', {},
|
|
@@ -68,8 +80,7 @@ export function jobChangeBanner(ui, change, { money, onAdopt = null }) {
|
|
|
68
80
|
return h('section', { class: 'fx-change', role: 'alert' },
|
|
69
81
|
h('div', { class: 'fx-change__head' },
|
|
70
82
|
h('p', {}, h('strong', {}, 'Problem.'), ' ', changeSummary(change, rows.filter((row) => row.field !== 'Total').length)),
|
|
71
|
-
h('div', { class: 'fx-change__acts' }, link,
|
|
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,
|
|
83
|
+
h('div', { class: 'fx-change__acts' }, link, accept, recheck)),
|
|
73
84
|
!change.link && order ? h('p', { class: 'fx-change__note' }, `Find ${order} in ${system} to fix it there.`) : null,
|
|
74
85
|
rows.length ? h('div', { class: 'fx-change__scroll' }, h('table', { class: 'fx-change__table' },
|
|
75
86
|
h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, 'Field'), h('th', { scope: 'col' }, 'Was'), h('th', { scope: 'col' }, `Now in ${system}`))),
|