@arcforgelabs/flow 2026.10.43 → 2026.10.45
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/api/invoicing.js +53 -3
- package/server/auth/people.js +41 -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/release-channel.js +5 -2
- package/server/ui/app.js +53 -197
- package/server/ui/columns.js +286 -0
- package/server/ui/flow.css +230 -43
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +52 -29
- package/server/ui/invoicing.js +219 -72
- package/server/ui/job-view.js +276 -60
- package/server/ui/jobs.js +133 -137
- package/server/ui/kit.js +279 -0
- package/server/ui/list-views.js +115 -0
- package/server/ui/monitoring.js +51 -8
- package/server/ui/pager.js +67 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +27 -7
- 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/view-bar.js +176 -70
package/server/ui/invoicing.js
CHANGED
|
@@ -10,7 +10,12 @@ 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
12
|
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
13
|
-
import {
|
|
13
|
+
import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
14
|
+
import { filterCount, filterJobs, INVOICE_COLUMNS, normaliseListView } from '/assets/ui/job-view.js';
|
|
15
|
+
import { INVOICE_FILTER_FIELDS, invoiceFilterKind, invoiceValue, normaliseInvoicesView } from '/assets/ui/list-views.js';
|
|
16
|
+
import { listPager, PAGE_SIZES } from '/assets/ui/pager.js';
|
|
17
|
+
import { savedViews } from '/assets/ui/saved-views.js';
|
|
18
|
+
import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
|
|
14
19
|
|
|
15
20
|
// The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
|
|
16
21
|
// person must look), Ready (checks pass), then the accounting system's
|
|
@@ -110,48 +115,123 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
|
|
|
110
115
|
|
|
111
116
|
// ── The list ───────────────────────────────────────────────────────────────
|
|
112
117
|
|
|
118
|
+
// The filter's fields and rules: list-views.js. The server checks saved
|
|
119
|
+
// views with them and filters every invoice of a tab (GET /invoices).
|
|
120
|
+
const FILTER_FIELDS = INVOICE_FILTER_FIELDS;
|
|
121
|
+
const PROBLEM_OPS = { yes: 'Has one', no: 'Has none' };
|
|
122
|
+
const FILTER_KEY = 'flow_invoice_filters';
|
|
123
|
+
const SIZE_KEY = 'flow_invoice_page_size';
|
|
124
|
+
const filterKind = invoiceFilterKind;
|
|
125
|
+
export { invoiceValue };
|
|
126
|
+
|
|
127
|
+
// The invoices a filter view keeps.
|
|
128
|
+
export const keptInvoices = (invoices, view, now = today()) => filterJobs(invoices, view, { valueOf: invoiceValue, kindOf: filterKind, today: now });
|
|
129
|
+
|
|
130
|
+
// Rows per page, kept on this device.
|
|
131
|
+
function readSize() {
|
|
132
|
+
let size = null;
|
|
133
|
+
try { size = Number(localStorage.getItem(SIZE_KEY)); } catch { /* none kept */ }
|
|
134
|
+
return PAGE_SIZES.includes(size) ? size : 100;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readFilters() {
|
|
138
|
+
try { return normaliseInvoicesView(JSON.parse(localStorage.getItem(FILTER_KEY))); } catch { return normaliseInvoicesView(null); }
|
|
139
|
+
}
|
|
140
|
+
|
|
113
141
|
export async function renderInvoiceList(main, ui) {
|
|
114
|
-
const { h, flow,
|
|
142
|
+
const { h, flow, listHead, listFrame, notice, go, toast } = ui;
|
|
115
143
|
const saved = OLD_TAB[sessionStorage.getItem('flow_invoice_tab')] ?? sessionStorage.getItem('flow_invoice_tab');
|
|
116
144
|
// Back from an invoice comes here with the tab, search and scroll it left.
|
|
117
145
|
const back = readNav();
|
|
118
146
|
const returning = sessionStorage.getItem(NAV_BACK) === '1';
|
|
119
147
|
sessionStorage.removeItem(NAV_BACK);
|
|
120
|
-
const state = {
|
|
148
|
+
const state = {
|
|
149
|
+
status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set(), view: readFilters(),
|
|
150
|
+
// The page of rows shown: the server filters and cuts it (GET /invoices).
|
|
151
|
+
offset: returning ? back?.offset ?? 0 : 0, limit: readSize(),
|
|
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, {
|
|
124
171
|
label: 'Find a number, client or reference', value: state.q,
|
|
125
172
|
oninput: (words) => {
|
|
126
173
|
clearTimeout(timer);
|
|
127
|
-
timer = setTimeout(() => { state.q = words.trim(); load(); }, 200);
|
|
174
|
+
timer = setTimeout(() => { state.q = words.trim(); state.offset = 0; load(); }, 200);
|
|
128
175
|
},
|
|
129
176
|
});
|
|
177
|
+
// The Filter chip: the same panel as on Jobs and Reconcile (view-bar.js).
|
|
178
|
+
// The server filters every invoice of the tab shown.
|
|
179
|
+
// A change to the filter: kept in this browser, and the first page loads.
|
|
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
|
+
state.offset = 0;
|
|
185
|
+
if (raw) load();
|
|
186
|
+
}
|
|
187
|
+
// The person's named views (saved-views.js): their default opens on a
|
|
188
|
+
// new visit.
|
|
189
|
+
const views = savedViews(ui, { page: 'invoices', current: () => state.view, apply: setView });
|
|
190
|
+
const opening = views.opening();
|
|
191
|
+
if (opening) state.view = normaliseInvoicesView(opening);
|
|
192
|
+
views.draw();
|
|
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' ? (raw?.clients ?? []).map((v) => ({ value: v, label: v })) : []),
|
|
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);
|
|
206
|
+
// The pages, in the foot under the rows (pager.js).
|
|
207
|
+
const pager = listPager(h, {
|
|
208
|
+
label: 'Invoice pages',
|
|
209
|
+
change: ({ offset, limit }) => {
|
|
210
|
+
if (limit !== state.limit) { try { localStorage.setItem(SIZE_KEY, String(limit)); } catch { /* it forgets */ } }
|
|
211
|
+
Object.assign(state, { offset, limit });
|
|
212
|
+
state.ticked.clear();
|
|
213
|
+
// A new page opens at its first row.
|
|
214
|
+
frame.body.scrollTop = 0;
|
|
215
|
+
load();
|
|
216
|
+
},
|
|
217
|
+
});
|
|
132
218
|
const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
|
|
133
219
|
let body = null;
|
|
134
|
-
|
|
220
|
+
let raw = null;
|
|
221
|
+
const openTab = (id) => { state.status = id; state.offset = 0; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
|
|
135
222
|
const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
|
|
136
223
|
// The tabs and search stay at the top while the rows scroll up under them;
|
|
137
224
|
// the bulk bar floats at the foot (ClickUp's layout). Sending is a bulk
|
|
138
225
|
// action: tick rows (the head tick takes every row shown), then Send.
|
|
139
|
-
const head =
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
226
|
+
const head = listHead('Invoices', {
|
|
227
|
+
actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
|
|
228
|
+
views: views.element, start: tabs, end: [controls, find.element], line: true, sticky: true,
|
|
229
|
+
});
|
|
230
|
+
const [title, ...top] = head.nodes;
|
|
231
|
+
const frame = listFrame(main, host);
|
|
232
|
+
// The pages sit in the window's foot bar, beside the count.
|
|
233
|
+
frame.tools.append(pager.element);
|
|
234
|
+
main.append(title, alerts.element, ...top, frame.element, bulk.element);
|
|
155
235
|
|
|
156
236
|
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
157
237
|
// The bulk bar for the ticked rows: what a person can do with this view.
|
|
@@ -307,7 +387,11 @@ export async function renderInvoiceList(main, ui) {
|
|
|
307
387
|
const seen = new Map();
|
|
308
388
|
let asked = 0;
|
|
309
389
|
async function load() {
|
|
310
|
-
const query = new URLSearchParams({
|
|
390
|
+
const query = new URLSearchParams({
|
|
391
|
+
status: state.status, offset: String(state.offset), limit: String(state.limit), today: today(),
|
|
392
|
+
...(state.q ? { q: state.q } : {}),
|
|
393
|
+
...(filterCount(state.view, filterKind) ? { filter: JSON.stringify(state.view) } : {}),
|
|
394
|
+
});
|
|
311
395
|
const key = String(query);
|
|
312
396
|
const mine = ++asked;
|
|
313
397
|
if (seen.has(key)) {
|
|
@@ -346,20 +430,33 @@ export async function renderInvoiceList(main, ui) {
|
|
|
346
430
|
}
|
|
347
431
|
|
|
348
432
|
async function draw(answer) {
|
|
433
|
+
raw = answer;
|
|
434
|
+
filterMenu.draw();
|
|
349
435
|
body = answer;
|
|
436
|
+
// A page past the end (rows went to another tab): show the last one.
|
|
437
|
+
if (!answer.invoices.length && answer.total && answer.offset) {
|
|
438
|
+
state.offset = Math.max(0, Math.floor((answer.total - 1) / state.limit) * state.limit);
|
|
439
|
+
load();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
pager.draw({ offset: answer.offset ?? 0, limit: answer.limit ?? state.limit, total: answer.total ?? answer.invoices.length });
|
|
350
443
|
const shown = new Set(body.invoices.map((i) => i.id));
|
|
444
|
+
const total = answer.total ?? body.invoices.length;
|
|
445
|
+
frame.count(`${total.toLocaleString('en-AU')} ${total === 1 ? 'invoice' : 'invoices'}${total ? ` · ${formatCents(answer.totalCents ?? 0)}` : ''}`);
|
|
351
446
|
for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
|
|
352
447
|
drawTabs(body.counts);
|
|
353
448
|
drawBar();
|
|
354
449
|
if (!body.invoices.length) {
|
|
355
|
-
host.replaceChildren(h('p', { class: 'fx-empty' }, state.
|
|
450
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, filterKind)
|
|
451
|
+
? 'No invoice in this tab passes the filter.'
|
|
452
|
+
: state.q
|
|
356
453
|
? 'No invoice matches that search.'
|
|
357
454
|
: EMPTY[state.status] ?? `No ${TAB_LABEL[state.status].toLowerCase()} invoices.`));
|
|
358
455
|
return;
|
|
359
456
|
}
|
|
360
457
|
// The invoice page steps through this list, in this order (navBar).
|
|
361
|
-
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); };
|
|
458
|
+
saveNav({ tab: state.status, q: state.q, offset: state.offset, ids: body.invoices.map((i) => i.id) });
|
|
459
|
+
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY, frameY: frame.body.scrollTop }); go(href); };
|
|
363
460
|
const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
|
|
364
461
|
const showProblem = state.status === 'pending';
|
|
365
462
|
const created = ['pending', 'ready'].includes(state.status);
|
|
@@ -391,37 +488,47 @@ export async function renderInvoiceList(main, ui) {
|
|
|
391
488
|
},
|
|
392
489
|
});
|
|
393
490
|
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
|
-
|
|
491
|
+
// The columns this tab has, in the person's order (columns.js).
|
|
492
|
+
const statusCell = (invoice) => {
|
|
493
|
+
const cell = h('span', {}, invoice.sending
|
|
494
|
+
? h('span', { class: 'fx-status', 'data-tone': 'info' }, 'Sending')
|
|
495
|
+
: h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] ?? 'mute' }, TAB_LABEL[invoice.tab] ?? ''));
|
|
496
|
+
if (!linked) statusCells.set(invoice.id, cell);
|
|
497
|
+
return cell;
|
|
498
|
+
};
|
|
499
|
+
const linkCell = (invoice) => {
|
|
500
|
+
const cell = h('span', { class: 'fx-links' }, systems.filter((s) => s.kind === 'work').map((s) => workIcon(ui, s, invoice.job)));
|
|
501
|
+
statusCells.set(invoice.id, cell);
|
|
502
|
+
return cell;
|
|
503
|
+
};
|
|
504
|
+
const problemCell = (invoice) => {
|
|
505
|
+
const first = invoice.problems?.[0];
|
|
506
|
+
return h('span', { class: 'fx-problem', title: invoice.problems.map((p) => p.message).join(' ') },
|
|
507
|
+
first?.short ?? first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1}`) : null);
|
|
508
|
+
};
|
|
509
|
+
const href = (invoice) => `/invoices/${invoice.id}`;
|
|
510
|
+
const ALL = {
|
|
511
|
+
number: numbered ? { kind: 'number', cell: (i) => link(href(i), i.number ?? 'Draft') } : null,
|
|
512
|
+
client: { kind: 'client', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
|
|
513
|
+
reference: showProblem
|
|
514
|
+
? { label: 'Problem', kind: 'text', cell: problemCell }
|
|
515
|
+
: { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
|
|
516
|
+
date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) => showDate(i.issueDate ?? i.createdAt?.slice(0, 10)) },
|
|
517
|
+
total: { kind: 'total', num: true, className: 'fx-strong', cell: (i) => formatCents(i.totalCents) },
|
|
518
|
+
status: { kind: 'status', className: 'fx-status-cell', cell: statusCell },
|
|
519
|
+
linked: linked ? { kind: 'link', cell: linkCell } : null,
|
|
520
|
+
};
|
|
521
|
+
available = Object.keys(ALL).filter((id) => ALL[id]);
|
|
522
|
+
const columns = state.columns.columns.filter((id) => ALL[id]).map((id) => ({ id, label: INVOICE_COLUMNS[id], ...ALL[id] }));
|
|
523
|
+
host.replaceChildren(heads.table({
|
|
524
|
+
className: 'fx-table--rows',
|
|
525
|
+
columns,
|
|
526
|
+
lead: [h('th', { class: 'fx-tick-cell' }, all)],
|
|
527
|
+
rows: h('tbody', {}, body.invoices.map((invoice) => h('tr', { tabindex: '0', onclick: () => open(href(invoice)), onkeydown: (event) => { if (event.key === 'Enter') open(href(invoice)); } },
|
|
528
|
+
h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)),
|
|
529
|
+
rowCells(h, columns, invoice),
|
|
530
|
+
h('td', { class: 'fx-c-end' })))),
|
|
531
|
+
}));
|
|
425
532
|
// Issued and void invoices also show Xero's status, from Flow's copy:
|
|
426
533
|
// in the Linked column every one gets an icon, so one not in Xero
|
|
427
534
|
// stands out.
|
|
@@ -437,6 +544,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
437
544
|
}
|
|
438
545
|
await load();
|
|
439
546
|
if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
|
|
547
|
+
if (returning && back?.frameY) frame.body.scrollTop = back.frameY;
|
|
440
548
|
}
|
|
441
549
|
|
|
442
550
|
// A file the browser saves, from a blob made on this page.
|
|
@@ -499,7 +607,8 @@ function navBar(ui, invoice) {
|
|
|
499
607
|
return bar;
|
|
500
608
|
}
|
|
501
609
|
|
|
502
|
-
// After a send, an Approve or a Save
|
|
610
|
+
// After a send, an Approve or a Save that makes a Pending invoice Ready, the
|
|
611
|
+
// next invoice of the list
|
|
503
612
|
// opens; at its end, the list. So a person reviews drafts one after another
|
|
504
613
|
// without going back. The invoice stays in the bar's list, so the count holds
|
|
505
614
|
// (2 of 35) and Previous opens it again, now done. Back reloads the list
|
|
@@ -595,9 +704,13 @@ function linkedCard(ui, invoice, accountingStatus = null) {
|
|
|
595
704
|
facts);
|
|
596
705
|
}
|
|
597
706
|
|
|
707
|
+
// The first sentence of a problem: what is wrong. The red field shows where,
|
|
708
|
+
// so the banner holds no how-to.
|
|
709
|
+
const brief = (text) => String(text ?? '').match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? String(text ?? '');
|
|
710
|
+
|
|
598
711
|
// A draft, or a new invoice before its first save.
|
|
599
712
|
function draftView(invoice, ui, context, redraw) {
|
|
600
|
-
const { h, flow, toast, go, notice } = ui;
|
|
713
|
+
const { h, flow, toast, go, notice, fieldError } = ui;
|
|
601
714
|
const isNew = !invoice;
|
|
602
715
|
const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
|
|
603
716
|
if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
|
|
@@ -636,9 +749,18 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
636
749
|
});
|
|
637
750
|
clientInput.addEventListener('blur', () => { setTimeout(() => { clientList.hidden = true; }, 120); });
|
|
638
751
|
|
|
639
|
-
const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: '
|
|
752
|
+
const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: 'Type a reference', 'aria-label': 'Reference' });
|
|
640
753
|
const dueDate = h('input', { class: 'fx-input', type: 'date', value: invoice?.dueDate ?? '' });
|
|
641
754
|
|
|
755
|
+
// The fields a failed check reads go red (problem.fields): a rule on the
|
|
756
|
+
// Reference marks the Reference box, a line with no price item marks its
|
|
757
|
+
// item. A check on something this page cannot change (the job's
|
|
758
|
+
// practitioner) marks nothing; the banner says it.
|
|
759
|
+
const problemList = invoice?.problems ?? [];
|
|
760
|
+
const failed = new Set(problemList.flatMap((p) => p.fields ?? []));
|
|
761
|
+
const badLines = new Set(problemList.filter((p) => p.fields?.includes('line_item')).map((p) => p.line));
|
|
762
|
+
const boxes = { reference };
|
|
763
|
+
|
|
642
764
|
// Lines.
|
|
643
765
|
let items = null;
|
|
644
766
|
const itemsReady = flow('/api/invoicing/price-items').then((body) => { items = body.priceItems ?? body.items ?? []; return items; }).catch(() => { items = []; return items; });
|
|
@@ -665,9 +787,10 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
665
787
|
};
|
|
666
788
|
showAmount();
|
|
667
789
|
const item = h('input', {
|
|
668
|
-
class: 'fx-input', type: 'text', list: 'fx-price-items', placeholder: 'Code
|
|
790
|
+
class: 'fx-input', type: 'text', list: 'fx-price-items', placeholder: 'Code', autocomplete: 'off',
|
|
669
791
|
value: line.itemCode ?? '', 'aria-label': `Item, line ${index + 1}`,
|
|
670
792
|
});
|
|
793
|
+
if (badLines.has(index + 1)) fieldError(item);
|
|
671
794
|
const description = h('input', { class: 'fx-input', type: 'text', value: line.description ?? '', 'aria-label': `Description, line ${index + 1}` });
|
|
672
795
|
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
796
|
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 +816,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
693
816
|
gst.addEventListener('change', () => { line.taxCode = gst.value; preview(); });
|
|
694
817
|
const remove = h('button', {
|
|
695
818
|
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(); },
|
|
819
|
+
onclick: () => { badLines.clear(); lines.splice(index, 1); if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); preview(); },
|
|
697
820
|
}, '×');
|
|
698
821
|
return h('tr', {},
|
|
699
822
|
h('td', { class: 'fx-lines__item' }, item),
|
|
@@ -734,7 +857,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
734
857
|
});
|
|
735
858
|
|
|
736
859
|
const check = () => {
|
|
737
|
-
if (!contact) return 'Choose a client from the list.';
|
|
860
|
+
if (!contact) { fieldError(clientInput); return 'Choose a client from the list.'; }
|
|
738
861
|
if (lines.some((line) => (line.description?.trim() || line.priceItemId) && line.unitAmountCents == null)) return 'Each line needs a price.';
|
|
739
862
|
return null;
|
|
740
863
|
};
|
|
@@ -753,22 +876,38 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
753
876
|
}
|
|
754
877
|
};
|
|
755
878
|
|
|
756
|
-
// Save
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
879
|
+
// Save is the check, like CI: it saves, and Flow runs every check again
|
|
880
|
+
// (Flow's own, the lab's rules, the accounting system's). A Pending
|
|
881
|
+
// invoice that now passes is Ready: a toast says so and the next invoice
|
|
882
|
+
// of the list opens. One that still fails stays Pending, with its red
|
|
883
|
+
// fields. A new invoice and a Pending one always offer Save; a Ready one
|
|
884
|
+
// only once something changed. There is no Flow "draft" step: Draft is
|
|
885
|
+
// the accounting system's word for an invoice it holds.
|
|
760
886
|
const keepsSave = isNew || invoice?.view !== 'ready';
|
|
761
887
|
const saveButton = h('button', {
|
|
762
888
|
class: 'fx-btn', type: 'button', hidden: !keepsSave,
|
|
763
889
|
onclick: async () => {
|
|
890
|
+
saveButton.disabled = true;
|
|
764
891
|
const saved = await save();
|
|
892
|
+
saveButton.disabled = false;
|
|
765
893
|
if (!saved) return;
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
894
|
+
const passes = saved.view === 'ready';
|
|
895
|
+
const why = brief(saved.problems?.[0]?.message);
|
|
896
|
+
if (isNew) {
|
|
897
|
+
toast(passes ? 'Saved. It is Ready.' : `Saved. It is Pending. ${why}`, passes ? 'success' : 'warn');
|
|
898
|
+
go(`/invoices/${saved.id}`, { replace: true });
|
|
899
|
+
} else if (passes && keepsSave) {
|
|
900
|
+
toast('Saved. It is Ready.', 'success');
|
|
901
|
+
openNext(ui, invoice.id);
|
|
902
|
+
} else if (passes) {
|
|
903
|
+
toast('Saved.', 'success');
|
|
904
|
+
redraw(saved);
|
|
905
|
+
} else {
|
|
906
|
+
toast(`Still Pending. ${why}`, 'danger');
|
|
907
|
+
redraw(saved);
|
|
908
|
+
}
|
|
770
909
|
},
|
|
771
|
-
},
|
|
910
|
+
}, 'Save');
|
|
772
911
|
const opened = JSON.stringify(body());
|
|
773
912
|
const changed = () => !isNew && JSON.stringify(body()) !== opened;
|
|
774
913
|
const watchChanges = () => { if (!keepsSave) saveButton.hidden = !changed(); };
|
|
@@ -852,8 +991,16 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
852
991
|
const problems = [
|
|
853
992
|
// Accepting changes the draft for the business: owners and admins only.
|
|
854
993
|
jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
|
|
855
|
-
otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
|
|
994
|
+
otherProblems.length ? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.') : null,
|
|
856
995
|
];
|
|
996
|
+
// A red field names the banner as its reason (for a screen reader).
|
|
997
|
+
const banner = problems[1];
|
|
998
|
+
if (banner) banner.id = 'fx-invoice-problem';
|
|
999
|
+
for (const name of failed) {
|
|
1000
|
+
if (!boxes[name]) continue;
|
|
1001
|
+
fieldError(boxes[name]);
|
|
1002
|
+
if (banner) boxes[name].setAttribute('aria-describedby', banner.id);
|
|
1003
|
+
}
|
|
857
1004
|
|
|
858
1005
|
return [
|
|
859
1006
|
navBar(ui, invoice),
|