@arcforgelabs/flow 2026.10.42 → 2026.10.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +4 -3
- package/package.json +5 -3
- package/server/auth/people.js +40 -2
- package/server/auth/routes.js +11 -0
- package/server/auth/session.js +22 -3
- package/server/core/custom-fields.js +4 -3
- package/server/core/invoice-rules.js +8 -3
- package/server/core/job-match.js +6 -2
- package/server/core/sending.js +4 -3
- package/server/integrations/sentry/index.js +9 -1
- package/server/release-channel.js +5 -2
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +46 -162
- package/server/ui/automations.js +9 -3
- package/server/ui/columns.js +268 -0
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +268 -55
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +92 -35
- package/server/ui/invoicing.js +206 -119
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/job-view.js +255 -60
- package/server/ui/jobs.js +197 -145
- package/server/ui/kit.js +278 -0
- package/server/ui/list-views.js +73 -0
- package/server/ui/monitoring.js +1 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- package/server/ui/reconcile.js +114 -61
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +141 -0
- package/server/ui/search.js +8 -0
- package/server/ui/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/view-bar.js +176 -70
- package/server/ui/xero-import.js +7 -10
package/server/ui/invoicing.js
CHANGED
|
@@ -9,8 +9,12 @@ 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
11
|
import { caseNumber } from '/assets/ui/job-board.js';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
13
|
+
import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
14
|
+
import { filterJobs, INVOICE_COLUMNS, normaliseListView } from '/assets/ui/job-view.js';
|
|
15
|
+
import { INVOICE_FILTER_FIELDS, normaliseInvoicesView } from '/assets/ui/list-views.js';
|
|
16
|
+
import { savedViews } from '/assets/ui/saved-views.js';
|
|
17
|
+
import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
|
|
14
18
|
|
|
15
19
|
// The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
|
|
16
20
|
// person must look), Ready (checks pass), then the accounting system's
|
|
@@ -110,14 +114,57 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
|
|
|
110
114
|
|
|
111
115
|
// ── The list ───────────────────────────────────────────────────────────────
|
|
112
116
|
|
|
117
|
+
// The filter's fields: list-views.js (the server checks saved views with them).
|
|
118
|
+
const FILTER_FIELDS = INVOICE_FILTER_FIELDS;
|
|
119
|
+
const PROBLEM_OPS = { yes: 'Has one', no: 'Has none' };
|
|
120
|
+
const FILTER_KEY = 'flow_invoice_filters';
|
|
121
|
+
const filterKind = (field) => FILTER_FIELDS[field]?.kind ?? null;
|
|
122
|
+
|
|
123
|
+
// An invoice's value for a filter field, as the filter compares it.
|
|
124
|
+
export function invoiceValue(invoice, field) {
|
|
125
|
+
switch (field) {
|
|
126
|
+
case 'client': return invoice.contactName ?? null;
|
|
127
|
+
case 'number': return invoice.number ?? null;
|
|
128
|
+
case 'reference': return invoice.reference ?? null;
|
|
129
|
+
case 'date': return invoice.issueDate ?? invoice.createdAt?.slice(0, 10) ?? null;
|
|
130
|
+
case 'total': return invoice.totalCents == null ? null : invoice.totalCents / 100;
|
|
131
|
+
case 'problem': return (invoice.problems?.length ?? 0) > 0;
|
|
132
|
+
default: return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The invoices a filter view keeps.
|
|
137
|
+
export const keptInvoices = (invoices, view, now = today()) => filterJobs(invoices, view, { valueOf: invoiceValue, kindOf: filterKind, today: now });
|
|
138
|
+
|
|
139
|
+
function readFilters() {
|
|
140
|
+
try { return normaliseInvoicesView(JSON.parse(localStorage.getItem(FILTER_KEY))); } catch { return normaliseInvoicesView(null); }
|
|
141
|
+
}
|
|
142
|
+
|
|
113
143
|
export async function renderInvoiceList(main, ui) {
|
|
114
|
-
const { h, flow,
|
|
144
|
+
const { h, flow, listHead, listFrame, notice, go, toast } = ui;
|
|
115
145
|
const saved = OLD_TAB[sessionStorage.getItem('flow_invoice_tab')] ?? sessionStorage.getItem('flow_invoice_tab');
|
|
116
146
|
// Back from an invoice comes here with the tab, search and scroll it left.
|
|
117
147
|
const back = readNav();
|
|
118
148
|
const returning = sessionStorage.getItem(NAV_BACK) === '1';
|
|
119
149
|
sessionStorage.removeItem(NAV_BACK);
|
|
120
|
-
const state = {
|
|
150
|
+
const state = {
|
|
151
|
+
status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set(), view: readFilters(),
|
|
152
|
+
columns: normaliseListView(ui.me?.views?.invoices, INVOICE_COLUMNS),
|
|
153
|
+
};
|
|
154
|
+
// The list's columns: resize, move, hide, kept with the account. A tab
|
|
155
|
+
// has some of them (`available`, set as it draws).
|
|
156
|
+
let available = Object.keys(INVOICE_COLUMNS);
|
|
157
|
+
const saveColumns = viewSaver(ui, 'invoices');
|
|
158
|
+
const heads = columnTable(h, {
|
|
159
|
+
order: () => state.columns.columns,
|
|
160
|
+
widths: () => state.columns.widths,
|
|
161
|
+
change: (patch) => {
|
|
162
|
+
state.columns = normaliseListView({ ...state.columns, ...patch }, INVOICE_COLUMNS);
|
|
163
|
+
saveColumns(state.columns);
|
|
164
|
+
if (raw) draw(raw);
|
|
165
|
+
},
|
|
166
|
+
hidden: () => available.filter((id) => !state.columns.columns.includes(id)).map((id) => ({ id, label: INVOICE_COLUMNS[id] })),
|
|
167
|
+
});
|
|
121
168
|
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
|
|
122
169
|
let timer;
|
|
123
170
|
const find = findBox(h, {
|
|
@@ -127,31 +174,50 @@ export async function renderInvoiceList(main, ui) {
|
|
|
127
174
|
timer = setTimeout(() => { state.q = words.trim(); load(); }, 200);
|
|
128
175
|
},
|
|
129
176
|
});
|
|
177
|
+
// The Filter chip: the same panel as on Jobs and Reconcile (view-bar.js).
|
|
178
|
+
// It filters the rows of the tab shown, on this device.
|
|
179
|
+
// A change to the filter: kept in this browser, and drawn.
|
|
180
|
+
function setView(patch) {
|
|
181
|
+
state.view = normaliseInvoicesView({ ...state.view, ...patch });
|
|
182
|
+
try { localStorage.setItem(FILTER_KEY, JSON.stringify(state.view)); } catch { /* the page still works, it forgets */ }
|
|
183
|
+
views.draw();
|
|
184
|
+
if (raw) draw(raw);
|
|
185
|
+
}
|
|
186
|
+
// The person's named views (saved-views.js): their default opens on a
|
|
187
|
+
// new visit.
|
|
188
|
+
const views = savedViews(ui, { page: 'invoices', current: () => state.view, apply: setView });
|
|
189
|
+
const opening = views.opening();
|
|
190
|
+
if (opening) state.view = normaliseInvoicesView(opening);
|
|
191
|
+
views.draw();
|
|
192
|
+
const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
|
|
193
|
+
const filterMenu = viewBar(h).filterMenu({
|
|
194
|
+
fields: () => Object.entries(FILTER_FIELDS).map(([value, f]) => ({ value, label: f.label, icon: f.icon })),
|
|
195
|
+
kindOf: filterKind,
|
|
196
|
+
opsOf: (field) => (field === 'problem' ? PROBLEM_OPS : null),
|
|
197
|
+
choicesOf: (field) => (field === 'client' ? labels((raw?.invoices ?? []).map((i) => i.contactName)) : []),
|
|
198
|
+
view: () => state.view,
|
|
199
|
+
change: setView,
|
|
200
|
+
first: 'client',
|
|
201
|
+
empty: 'No filters. Every invoice in the tab shows.',
|
|
202
|
+
});
|
|
203
|
+
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Filter' }, ...filterMenu.node);
|
|
130
204
|
const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
131
205
|
const bulk = bulkBar(ui);
|
|
132
206
|
const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
|
|
133
207
|
let body = null;
|
|
208
|
+
let raw = null;
|
|
134
209
|
const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
|
|
135
210
|
const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
|
|
136
211
|
// The tabs and search stay at the top while the rows scroll up under them;
|
|
137
212
|
// the bulk bar floats at the foot (ClickUp's layout). Sending is a bulk
|
|
138
213
|
// action: tick rows (the head tick takes every row shown), then Send.
|
|
139
|
-
const head =
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
edge,
|
|
147
|
-
head,
|
|
148
|
-
host,
|
|
149
|
-
bulk.element);
|
|
150
|
-
// A shadow under the head shows once rows scroll under it.
|
|
151
|
-
if (typeof IntersectionObserver === 'function') {
|
|
152
|
-
new IntersectionObserver(([entry]) => head.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${head.offsetTop > 0 && window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1}px 0px 0px 0px` }).observe(edge);
|
|
153
|
-
}
|
|
154
|
-
|
|
214
|
+
const head = listHead('Invoices', {
|
|
215
|
+
actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
|
|
216
|
+
views: views.element, start: tabs, end: [controls, find.element], line: true, sticky: true,
|
|
217
|
+
});
|
|
218
|
+
const [title, ...top] = head.nodes;
|
|
219
|
+
const frame = listFrame(main, host);
|
|
220
|
+
main.append(title, alerts.element, ...top, frame.element, bulk.element);
|
|
155
221
|
|
|
156
222
|
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
157
223
|
// The bulk bar for the ticked rows: what a person can do with this view.
|
|
@@ -346,20 +412,25 @@ export async function renderInvoiceList(main, ui) {
|
|
|
346
412
|
}
|
|
347
413
|
|
|
348
414
|
async function draw(answer) {
|
|
349
|
-
|
|
415
|
+
raw = answer;
|
|
416
|
+
filterMenu.draw();
|
|
417
|
+
body = { ...answer, invoices: keptInvoices(answer.invoices, state.view) };
|
|
350
418
|
const shown = new Set(body.invoices.map((i) => i.id));
|
|
419
|
+
frame.count(plural(body.invoices.length, 'invoice', 'invoices'));
|
|
351
420
|
for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
|
|
352
421
|
drawTabs(body.counts);
|
|
353
422
|
drawBar();
|
|
354
423
|
if (!body.invoices.length) {
|
|
355
|
-
host.replaceChildren(h('p', { class: 'fx-empty' },
|
|
424
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, answer.invoices.length
|
|
425
|
+
? 'No invoice in this tab passes the filter.'
|
|
426
|
+
: state.q
|
|
356
427
|
? 'No invoice matches that search.'
|
|
357
428
|
: EMPTY[state.status] ?? `No ${TAB_LABEL[state.status].toLowerCase()} invoices.`));
|
|
358
429
|
return;
|
|
359
430
|
}
|
|
360
431
|
// The invoice page steps through this list, in this order (navBar).
|
|
361
432
|
saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
|
|
362
|
-
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
|
|
433
|
+
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY, frameY: frame.body.scrollTop }); go(href); };
|
|
363
434
|
const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
|
|
364
435
|
const showProblem = state.status === 'pending';
|
|
365
436
|
const created = ['pending', 'ready'].includes(state.status);
|
|
@@ -391,37 +462,47 @@ export async function renderInvoiceList(main, ui) {
|
|
|
391
462
|
},
|
|
392
463
|
});
|
|
393
464
|
const statusCells = new Map();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
465
|
+
// The columns this tab has, in the person's order (columns.js).
|
|
466
|
+
const statusCell = (invoice) => {
|
|
467
|
+
const cell = h('span', {}, invoice.sending
|
|
468
|
+
? h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Sending')
|
|
469
|
+
: h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute' }, TAB_LABEL[invoice.tab] ?? ''));
|
|
470
|
+
if (!linked) statusCells.set(invoice.id, cell);
|
|
471
|
+
return cell;
|
|
472
|
+
};
|
|
473
|
+
const linkCell = (invoice) => {
|
|
474
|
+
const cell = h('span', { class: 'fx-links' }, systems.filter((s) => s.kind === 'work').map((s) => workIcon(ui, s, invoice.job)));
|
|
475
|
+
statusCells.set(invoice.id, cell);
|
|
476
|
+
return cell;
|
|
477
|
+
};
|
|
478
|
+
const problemCell = (invoice) => {
|
|
479
|
+
const first = invoice.problems?.[0];
|
|
480
|
+
return h('span', { class: 'fx-problem', title: invoice.problems.map((p) => p.message).join(' ') },
|
|
481
|
+
first?.short ?? first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1}`) : null);
|
|
482
|
+
};
|
|
483
|
+
const href = (invoice) => `/invoices/${invoice.id}`;
|
|
484
|
+
const ALL = {
|
|
485
|
+
number: numbered ? { kind: 'number', cell: (i) => link(href(i), i.number ?? 'Draft') } : null,
|
|
486
|
+
client: { kind: 'client', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
|
|
487
|
+
reference: showProblem
|
|
488
|
+
? { label: 'Problem', kind: 'text', cell: problemCell }
|
|
489
|
+
: { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
|
|
490
|
+
date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) => showDate(i.issueDate ?? i.createdAt?.slice(0, 10)) },
|
|
491
|
+
total: { kind: 'total', num: true, className: 'fx-strong', cell: (i) => formatCents(i.totalCents) },
|
|
492
|
+
status: { kind: 'status', className: 'fx-status-cell', cell: statusCell },
|
|
493
|
+
linked: linked ? { kind: 'link', cell: linkCell } : null,
|
|
494
|
+
};
|
|
495
|
+
available = Object.keys(ALL).filter((id) => ALL[id]);
|
|
496
|
+
const columns = state.columns.columns.filter((id) => ALL[id]).map((id) => ({ id, label: INVOICE_COLUMNS[id], ...ALL[id] }));
|
|
497
|
+
host.replaceChildren(heads.table({
|
|
498
|
+
className: 'fx-table--rows',
|
|
499
|
+
columns,
|
|
500
|
+
lead: [h('th', { class: 'fx-tick-cell' }, all)],
|
|
501
|
+
rows: h('tbody', {}, body.invoices.map((invoice) => h('tr', { tabindex: '0', onclick: () => open(href(invoice)), onkeydown: (event) => { if (event.key === 'Enter') open(href(invoice)); } },
|
|
502
|
+
h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)),
|
|
503
|
+
rowCells(h, columns, invoice),
|
|
504
|
+
h('td', { class: 'fx-c-end' })))),
|
|
505
|
+
}));
|
|
425
506
|
// Issued and void invoices also show Xero's status, from Flow's copy:
|
|
426
507
|
// in the Linked column every one gets an icon, so one not in Xero
|
|
427
508
|
// stands out.
|
|
@@ -437,6 +518,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
437
518
|
}
|
|
438
519
|
await load();
|
|
439
520
|
if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
|
|
521
|
+
if (returning && back?.frameY) frame.body.scrollTop = back.frameY;
|
|
440
522
|
}
|
|
441
523
|
|
|
442
524
|
// A file the browser saves, from a blob made on this page.
|
|
@@ -499,7 +581,8 @@ function navBar(ui, invoice) {
|
|
|
499
581
|
return bar;
|
|
500
582
|
}
|
|
501
583
|
|
|
502
|
-
// After a send, an Approve or a Save
|
|
584
|
+
// After a send, an Approve or a Save that makes a Pending invoice Ready, the
|
|
585
|
+
// next invoice of the list
|
|
503
586
|
// opens; at its end, the list. So a person reviews drafts one after another
|
|
504
587
|
// without going back. The invoice stays in the bar's list, so the count holds
|
|
505
588
|
// (2 of 35) and Previous opens it again, now done. Back reloads the list
|
|
@@ -595,9 +678,13 @@ function linkedCard(ui, invoice, accountingStatus = null) {
|
|
|
595
678
|
facts);
|
|
596
679
|
}
|
|
597
680
|
|
|
681
|
+
// The first sentence of a problem: what is wrong. The red field shows where,
|
|
682
|
+
// so the banner holds no how-to.
|
|
683
|
+
const brief = (text) => String(text ?? '').match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? String(text ?? '');
|
|
684
|
+
|
|
598
685
|
// A draft, or a new invoice before its first save.
|
|
599
686
|
function draftView(invoice, ui, context, redraw) {
|
|
600
|
-
const { h, flow, toast, go, notice } = ui;
|
|
687
|
+
const { h, flow, toast, go, notice, fieldError } = ui;
|
|
601
688
|
const isNew = !invoice;
|
|
602
689
|
const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
|
|
603
690
|
if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
|
|
@@ -636,9 +723,18 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
636
723
|
});
|
|
637
724
|
clientInput.addEventListener('blur', () => { setTimeout(() => { clientList.hidden = true; }, 120); });
|
|
638
725
|
|
|
639
|
-
const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: '
|
|
726
|
+
const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: 'Type a reference', 'aria-label': 'Reference' });
|
|
640
727
|
const dueDate = h('input', { class: 'fx-input', type: 'date', value: invoice?.dueDate ?? '' });
|
|
641
728
|
|
|
729
|
+
// The fields a failed check reads go red (problem.fields): a rule on the
|
|
730
|
+
// Reference marks the Reference box, a line with no price item marks its
|
|
731
|
+
// item. A check on something this page cannot change (the job's
|
|
732
|
+
// practitioner) marks nothing; the banner says it.
|
|
733
|
+
const problemList = invoice?.problems ?? [];
|
|
734
|
+
const failed = new Set(problemList.flatMap((p) => p.fields ?? []));
|
|
735
|
+
const badLines = new Set(problemList.filter((p) => p.fields?.includes('line_item')).map((p) => p.line));
|
|
736
|
+
const boxes = { reference };
|
|
737
|
+
|
|
642
738
|
// Lines.
|
|
643
739
|
let items = null;
|
|
644
740
|
const itemsReady = flow('/api/invoicing/price-items').then((body) => { items = body.priceItems ?? body.items ?? []; return items; }).catch(() => { items = []; return items; });
|
|
@@ -665,9 +761,10 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
665
761
|
};
|
|
666
762
|
showAmount();
|
|
667
763
|
const item = h('input', {
|
|
668
|
-
class: 'fx-input', type: 'text', list: 'fx-price-items', placeholder: 'Code
|
|
764
|
+
class: 'fx-input', type: 'text', list: 'fx-price-items', placeholder: 'Code', autocomplete: 'off',
|
|
669
765
|
value: line.itemCode ?? '', 'aria-label': `Item, line ${index + 1}`,
|
|
670
766
|
});
|
|
767
|
+
if (badLines.has(index + 1)) fieldError(item);
|
|
671
768
|
const description = h('input', { class: 'fx-input', type: 'text', value: line.description ?? '', 'aria-label': `Description, line ${index + 1}` });
|
|
672
769
|
const quantity = h('input', { class: 'fx-input fx-num', type: 'number', min: '1', step: '1', value: String(line.quantity ?? 1), 'aria-label': `Quantity, line ${index + 1}` });
|
|
673
770
|
const price = h('input', { class: 'fx-input fx-num', type: 'text', inputmode: 'decimal', value: fromCents(line.unitAmountCents), placeholder: '0.00', 'aria-label': `Unit price, line ${index + 1}` });
|
|
@@ -693,7 +790,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
693
790
|
gst.addEventListener('change', () => { line.taxCode = gst.value; preview(); });
|
|
694
791
|
const remove = h('button', {
|
|
695
792
|
class: 'fx-btn fx-btn--ghost fx-btn--sm fx-btn--icon', type: 'button', title: 'Remove line', 'aria-label': `Remove line ${index + 1}`,
|
|
696
|
-
onclick: () => { lines.splice(index, 1); if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); preview(); },
|
|
793
|
+
onclick: () => { badLines.clear(); lines.splice(index, 1); if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); preview(); },
|
|
697
794
|
}, '×');
|
|
698
795
|
return h('tr', {},
|
|
699
796
|
h('td', { class: 'fx-lines__item' }, item),
|
|
@@ -734,7 +831,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
734
831
|
});
|
|
735
832
|
|
|
736
833
|
const check = () => {
|
|
737
|
-
if (!contact) return 'Choose a client from the list.';
|
|
834
|
+
if (!contact) { fieldError(clientInput); return 'Choose a client from the list.'; }
|
|
738
835
|
if (lines.some((line) => (line.description?.trim() || line.priceItemId) && line.unitAmountCents == null)) return 'Each line needs a price.';
|
|
739
836
|
return null;
|
|
740
837
|
};
|
|
@@ -753,22 +850,38 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
753
850
|
}
|
|
754
851
|
};
|
|
755
852
|
|
|
756
|
-
// Save
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
853
|
+
// Save is the check, like CI: it saves, and Flow runs every check again
|
|
854
|
+
// (Flow's own, the lab's rules, the accounting system's). A Pending
|
|
855
|
+
// invoice that now passes is Ready: a toast says so and the next invoice
|
|
856
|
+
// of the list opens. One that still fails stays Pending, with its red
|
|
857
|
+
// fields. A new invoice and a Pending one always offer Save; a Ready one
|
|
858
|
+
// only once something changed. There is no Flow "draft" step: Draft is
|
|
859
|
+
// the accounting system's word for an invoice it holds.
|
|
760
860
|
const keepsSave = isNew || invoice?.view !== 'ready';
|
|
761
861
|
const saveButton = h('button', {
|
|
762
862
|
class: 'fx-btn', type: 'button', hidden: !keepsSave,
|
|
763
863
|
onclick: async () => {
|
|
864
|
+
saveButton.disabled = true;
|
|
764
865
|
const saved = await save();
|
|
866
|
+
saveButton.disabled = false;
|
|
765
867
|
if (!saved) return;
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
868
|
+
const passes = saved.view === 'ready';
|
|
869
|
+
const why = brief(saved.problems?.[0]?.message);
|
|
870
|
+
if (isNew) {
|
|
871
|
+
toast(passes ? 'Saved. It is Ready.' : `Saved. It is Pending. ${why}`, passes ? 'success' : 'warn');
|
|
872
|
+
go(`/invoices/${saved.id}`, { replace: true });
|
|
873
|
+
} else if (passes && keepsSave) {
|
|
874
|
+
toast('Saved. It is Ready.', 'success');
|
|
875
|
+
openNext(ui, invoice.id);
|
|
876
|
+
} else if (passes) {
|
|
877
|
+
toast('Saved.', 'success');
|
|
878
|
+
redraw(saved);
|
|
879
|
+
} else {
|
|
880
|
+
toast(`Still Pending. ${why}`, 'danger');
|
|
881
|
+
redraw(saved);
|
|
882
|
+
}
|
|
770
883
|
},
|
|
771
|
-
},
|
|
884
|
+
}, 'Save');
|
|
772
885
|
const opened = JSON.stringify(body());
|
|
773
886
|
const changed = () => !isNew && JSON.stringify(body()) !== opened;
|
|
774
887
|
const watchChanges = () => { if (!keepsSave) saveButton.hidden = !changed(); };
|
|
@@ -836,15 +949,12 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
836
949
|
dueDate.addEventListener('change', syncIssue);
|
|
837
950
|
syncIssue();
|
|
838
951
|
|
|
839
|
-
// A new invoice can start from a job that has no invoice yet: a
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
const fromJob = isNew && context.canIssue
|
|
844
|
-
h('
|
|
845
|
-
h('span', { class: 'fx-strong' }, 'Billing a job?'),
|
|
846
|
-
h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
|
|
847
|
-
h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
|
|
952
|
+
// A new invoice can start from a job that has no invoice yet: a button in
|
|
953
|
+
// the header. Flow drafts it the way the invoicing rules do (client,
|
|
954
|
+
// Reference, lines) and links it to the job. An invoice with no job is
|
|
955
|
+
// fine too.
|
|
956
|
+
const fromJob = isNew && context.canIssue
|
|
957
|
+
? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => pickJob(ui) }, 'From a job') : null;
|
|
848
958
|
|
|
849
959
|
// A draft with no problem is in Ready by itself; one with a problem is
|
|
850
960
|
// Pending. A changed job is fixed at its source or accepted here.
|
|
@@ -853,47 +963,26 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
853
963
|
// changed. Other problems stay one line.
|
|
854
964
|
const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
|
|
855
965
|
const problems = [
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
onAccept: !context.canIssue ? null : async () => {
|
|
860
|
-
const system = jobChange.systemName;
|
|
861
|
-
const lost = [jobChange.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, changed() ? 'Unsaved changes are lost.' : null].filter(Boolean);
|
|
862
|
-
if (lost.length) {
|
|
863
|
-
const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
|
|
864
|
-
if (!ok) return;
|
|
865
|
-
}
|
|
866
|
-
try {
|
|
867
|
-
const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
|
|
868
|
-
toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
|
|
869
|
-
redraw(res.invoice);
|
|
870
|
-
} catch (error) {
|
|
871
|
-
toast(error.message, 'danger');
|
|
872
|
-
}
|
|
873
|
-
},
|
|
874
|
-
// Reads this one job from the work system again, then every check runs.
|
|
875
|
-
onRecheck: async () => {
|
|
876
|
-
try {
|
|
877
|
-
const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/recheck`, { method: 'POST', body: '{}' });
|
|
878
|
-
toast(res.invoice?.jobChange ? `Still changed in ${jobChange.systemName}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
|
|
879
|
-
if (res.invoice) redraw(res.invoice);
|
|
880
|
-
} catch (error) {
|
|
881
|
-
toast(error.message, 'danger');
|
|
882
|
-
}
|
|
883
|
-
},
|
|
884
|
-
}) : null,
|
|
885
|
-
otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
|
|
966
|
+
// Accepting changes the draft for the business: owners and admins only.
|
|
967
|
+
jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
|
|
968
|
+
otherProblems.length ? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.') : null,
|
|
886
969
|
];
|
|
970
|
+
// A red field names the banner as its reason (for a screen reader).
|
|
971
|
+
const banner = problems[1];
|
|
972
|
+
if (banner) banner.id = 'fx-invoice-problem';
|
|
973
|
+
for (const name of failed) {
|
|
974
|
+
if (!boxes[name]) continue;
|
|
975
|
+
fieldError(boxes[name]);
|
|
976
|
+
if (banner) boxes[name].setAttribute('aria-describedby', banner.id);
|
|
977
|
+
}
|
|
887
978
|
|
|
888
979
|
return [
|
|
889
980
|
navBar(ui, invoice),
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton)),
|
|
981
|
+
ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
|
|
982
|
+
actions: [fromJob, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton].filter(Boolean),
|
|
983
|
+
}),
|
|
984
|
+
draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
|
|
895
985
|
...problems,
|
|
896
|
-
fromJob,
|
|
897
986
|
h('div', { class: 'fx-invoice__grid' },
|
|
898
987
|
h('div', { class: 'fx-invoice__main' },
|
|
899
988
|
h('section', { class: 'fx-card fx-invoice__head' },
|
|
@@ -999,10 +1088,8 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
999
1088
|
navBar(ui, invoice),
|
|
1000
1089
|
h('header', { class: 'fx-ph' },
|
|
1001
1090
|
h('div', { class: 'fx-ph__text' },
|
|
1002
|
-
h('
|
|
1003
|
-
|
|
1004
|
-
h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
|
|
1005
|
-
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · ${showDate(invoice.issueDate)}`)),
|
|
1091
|
+
h('h1', { class: 'fx-title' }, invoice.number),
|
|
1092
|
+
h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
|
|
1006
1093
|
h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
|
|
1007
1094
|
h('div', { class: 'fx-invoice__grid' },
|
|
1008
1095
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -1072,7 +1159,7 @@ function xeroCard(invoice, ui, context, status, reload) {
|
|
|
1072
1159
|
}
|
|
1073
1160
|
|
|
1074
1161
|
// Send to Xero (POST /api/xero/invoices/push). The customer's send mode
|
|
1075
|
-
// (Settings,
|
|
1162
|
+
// (Settings, Rules) decides what Xero gets: a draft that a person approves
|
|
1076
1163
|
// there, or an approved invoice that Xero emails to the customer.
|
|
1077
1164
|
function sendToXero(invoice, ui, context) {
|
|
1078
1165
|
const { h, flow, toast } = ui;
|
package/server/ui/job-board.js
CHANGED
|
@@ -93,7 +93,10 @@ function reasonFix(reason) {
|
|
|
93
93
|
if (reason === 'the work system report is out of date') {
|
|
94
94
|
return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.', go: 'integrations' };
|
|
95
95
|
}
|
|
96
|
-
|
|
96
|
+
// Review opens the job's own sidecar: what changed, and Accept change.
|
|
97
|
+
if (reason === 'the job changed after its invoice was made') {
|
|
98
|
+
return { label: 'Job changed', text: 'Changed after the draft was made.', todo: 'Fix it in the work system, or accept the change.', go: 'review' };
|
|
99
|
+
}
|
|
97
100
|
if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.', 'work');
|
|
98
101
|
if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.', 'work');
|
|
99
102
|
// The matcher found invoices that may be this job's, but no exact link.
|
|
@@ -134,27 +137,36 @@ function draftFix(problem) {
|
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
// Where a fix's link goes: Flow's invoice, the job in the work system, a
|
|
137
|
-
// Flow settings page,
|
|
138
|
-
// (jobs.js draws them).
|
|
139
|
-
export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'choose'];
|
|
140
|
+
// Flow settings page, `review`: the job's Review sidecar, or `choose`: Make
|
|
141
|
+
// invoice and Link invoice buttons (jobs.js draws them).
|
|
142
|
+
export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'review', 'choose'];
|
|
140
143
|
|
|
141
144
|
// What a person must fix on a job, in order: [{ label, text, todo, go }];
|
|
142
145
|
// `todo` is null when the buttons are the instruction.
|
|
143
|
-
// Empty unless invoiceStage is Problem.
|
|
146
|
+
// Empty unless invoiceStage is Problem. One fact is one fix: a job in review
|
|
147
|
+
// already says why, so its draft's copy of that hold (sending.js,
|
|
148
|
+
// `job_review`) is left out, and two fixes with the same words show once.
|
|
144
149
|
export function fixes(o) {
|
|
145
150
|
if (invoiceStage(o) !== 'problem') return [];
|
|
146
151
|
const found = [];
|
|
147
152
|
if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
|
|
148
153
|
if (o.view === 'ready') {
|
|
149
154
|
found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
|
|
150
|
-
label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings,
|
|
155
|
+
label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Rules.', go: 'invoicing-settings',
|
|
151
156
|
})));
|
|
152
157
|
}
|
|
153
158
|
if (o.invoiceStatus === 'void') {
|
|
154
159
|
found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
|
|
155
160
|
}
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
const held = o.view === 'review_required' && o.holdState === 'review';
|
|
162
|
+
found.push(...(o.invoiceProblems ?? []).filter((p) => !(held && p.code === 'job_review')).map((p) => draftFix(p)));
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
return found.filter((f) => {
|
|
165
|
+
const key = `${f.label}\u0000${f.text}`;
|
|
166
|
+
if (seen.has(key)) return false;
|
|
167
|
+
seen.add(key);
|
|
168
|
+
return true;
|
|
169
|
+
});
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
// The short words for a job's problems: the first label, and "+N" for more.
|
package/server/ui/job-change.js
CHANGED
|
@@ -87,3 +87,39 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
|
|
|
87
87
|
body)) : null,
|
|
88
88
|
more);
|
|
89
89
|
}
|
|
90
|
+
|
|
91
|
+
// The banner with Accept change and the recheck wired, for the invoice page
|
|
92
|
+
// and a job's Review sidecar (jobs.js). `canAccept`: the person may change
|
|
93
|
+
// drafts (owners and admins). `unsaved()`: edits on the page that Accept
|
|
94
|
+
// would lose. `done(invoice)` hears the invoice after Accept or a recheck.
|
|
95
|
+
export function jobChangeReview(ui, change, { money, canAccept = false, unsaved = () => false, done = () => {} }) {
|
|
96
|
+
const { h, flow, toast } = ui;
|
|
97
|
+
const system = change.systemName;
|
|
98
|
+
return jobChangeBanner(ui, change, {
|
|
99
|
+
money,
|
|
100
|
+
onAccept: !canAccept ? null : async () => {
|
|
101
|
+
const lost = [change.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, unsaved() ? 'Unsaved changes are lost.' : null].filter(Boolean);
|
|
102
|
+
if (lost.length) {
|
|
103
|
+
const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
|
|
104
|
+
if (!ok) return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const res = await flow(`/api/invoicing/jobs/${change.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
|
|
108
|
+
toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
|
|
109
|
+
await done(res.invoice);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
toast(error.message, 'danger');
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
// Reads this one job from the work system again, then every check runs.
|
|
115
|
+
onRecheck: async () => {
|
|
116
|
+
try {
|
|
117
|
+
const res = await flow(`/api/invoicing/jobs/${change.jobId}/recheck`, { method: 'POST', body: '{}' });
|
|
118
|
+
toast(res.invoice?.jobChange ? `Still changed in ${system}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
|
|
119
|
+
if (res.invoice) await done(res.invoice);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
toast(error.message, 'danger');
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|