@arcforgelabs/flow 2026.10.45 → 2026.10.47
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/VISION.md +10 -10
- package/npm-shrinkwrap.json +104 -2
- package/package.json +6 -2
- package/server/api/invoicing.js +56 -16
- package/server/app.js +4 -2
- package/server/auth/people.js +5 -3
- package/server/auth/routes.js +4 -1
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +40 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +76 -22
- package/server/integrations/call-guard.js +21 -4
- package/server/integrations/sentry/index.js +14 -7
- package/server/integrations/xero/client.js +12 -2
- package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/routes.js +29 -5
- package/server/integrations/xero/runtime.js +5 -0
- package/server/integrations/xero/usage.js +74 -0
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +110 -27
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +54 -2
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +353 -45
- package/server/ui/invoicing.js +286 -64
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +14 -4
- package/server/ui/jobs.js +156 -56
- package/server/ui/kit.js +212 -51
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +726 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +41 -0
- package/server/ui/page.js +110 -5
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +19 -18
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +6 -5
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- package/server/ui/xero-import.js +48 -19
- package/server/ui/xero-usage-model.js +106 -0
- package/server/ui/xero-usage.js +133 -0
package/server/ui/invoicing.js
CHANGED
|
@@ -10,10 +10,13 @@ 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 {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
13
|
+
import { rowMenu } from '/assets/ui/kit.js';
|
|
14
|
+
import { isPhone, WIDTHS } from '/assets/ui/mobile.js';
|
|
15
|
+
import { recordBar, watchPhonePrimary } from '/assets/ui/sidecar.js';
|
|
16
|
+
import { columnTable, dateCell, fieldColumn, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
17
|
+
import { fieldGroup, fieldIdOf, filterCount, filterJobs, INVOICE_COLUMNS, invoiceColumnsView, KIND_OF_TYPE } from '/assets/ui/job-view.js';
|
|
18
|
+
import { INVOICE_FILTER_FIELDS, INVOICE_FILTER_STATUSES, invoiceFilterKind, invoiceKindOf, invoiceValue, normaliseInvoicesView } from '/assets/ui/list-views.js';
|
|
19
|
+
import { listPager, pager, PAGE_SIZES } from '/assets/ui/pager.js';
|
|
17
20
|
import { savedViews } from '/assets/ui/saved-views.js';
|
|
18
21
|
import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
|
|
19
22
|
|
|
@@ -119,13 +122,14 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
|
|
|
119
122
|
// views with them and filters every invoice of a tab (GET /invoices).
|
|
120
123
|
const FILTER_FIELDS = INVOICE_FILTER_FIELDS;
|
|
121
124
|
const PROBLEM_OPS = { yes: 'Has one', no: 'Has none' };
|
|
125
|
+
const STATUS_OPS = { is: 'Is', not: 'Is not' };
|
|
122
126
|
const FILTER_KEY = 'flow_invoice_filters';
|
|
123
127
|
const SIZE_KEY = 'flow_invoice_page_size';
|
|
124
|
-
const filterKind = invoiceFilterKind;
|
|
125
128
|
export { invoiceValue };
|
|
126
129
|
|
|
127
|
-
// The invoices a filter view keeps.
|
|
128
|
-
|
|
130
|
+
// The invoices a filter view keeps. `kindOf` knows the lab's custom fields;
|
|
131
|
+
// `me` is the person looking, for "Me" on a person field.
|
|
132
|
+
export const keptInvoices = (invoices, view, now = today(), { kindOf = invoiceFilterKind, me = null } = {}) => filterJobs(invoices, view, { valueOf: invoiceValue, kindOf, late: null, me, today: now });
|
|
129
133
|
|
|
130
134
|
// Rows per page, kept on this device.
|
|
131
135
|
function readSize() {
|
|
@@ -149,21 +153,25 @@ export async function renderInvoiceList(main, ui) {
|
|
|
149
153
|
status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set(), view: readFilters(),
|
|
150
154
|
// The page of rows shown: the server filters and cuts it (GET /invoices).
|
|
151
155
|
offset: returning ? back?.offset ?? 0 : 0, limit: readSize(),
|
|
152
|
-
columns:
|
|
156
|
+
columns: invoiceColumnsView(ui.me?.views?.invoices),
|
|
157
|
+
// The lab's custom fields, their job values and the people a person
|
|
158
|
+
// field names (GET /api/custom-fields): columns a person can add.
|
|
159
|
+
fields: [], values: new Map(), people: [],
|
|
153
160
|
};
|
|
154
161
|
// The list's columns: resize, move, hide, kept with the account. A tab
|
|
155
|
-
// has some of them (`available`, set as it draws).
|
|
162
|
+
// has some of them (`available`, set as it draws), each with its label.
|
|
156
163
|
let available = Object.keys(INVOICE_COLUMNS);
|
|
164
|
+
const labelOf = (id) => INVOICE_COLUMNS[id] ?? state.fields.find((f) => fieldGroup(f.id) === id)?.name ?? id;
|
|
157
165
|
const saveColumns = viewSaver(ui, 'invoices');
|
|
158
166
|
const heads = columnTable(h, {
|
|
159
167
|
order: () => state.columns.columns,
|
|
160
168
|
widths: () => state.columns.widths,
|
|
161
169
|
change: (patch) => {
|
|
162
|
-
state.columns =
|
|
170
|
+
state.columns = invoiceColumnsView({ ...state.columns, ...patch });
|
|
163
171
|
saveColumns(state.columns);
|
|
164
172
|
if (raw) draw(raw);
|
|
165
173
|
},
|
|
166
|
-
hidden: () => available.filter((id) => !state.columns.columns.includes(id)).map((id) => ({ id, label:
|
|
174
|
+
hidden: () => available.filter((id) => !state.columns.columns.includes(id)).map((id) => ({ id, label: labelOf(id) })),
|
|
167
175
|
});
|
|
168
176
|
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
|
|
169
177
|
let timer;
|
|
@@ -190,11 +198,27 @@ export async function renderInvoiceList(main, ui) {
|
|
|
190
198
|
const opening = views.opening();
|
|
191
199
|
if (opening) state.view = normaliseInvoicesView(opening);
|
|
192
200
|
views.draw();
|
|
201
|
+
// The same fields the Columns menu offers: the invoice's columns, its
|
|
202
|
+
// job's columns, then the lab's custom fields (view-bar.js draws them).
|
|
203
|
+
const fieldById = (id) => state.fields.find((f) => f.id === id) ?? null;
|
|
204
|
+
const kindOf = (field) => invoiceKindOf(state.fields)(field);
|
|
205
|
+
const labels = (list) => (list ?? []).filter(Boolean).map((v) => ({ value: v, label: v }));
|
|
193
206
|
const filterMenu = viewBar(h).filterMenu({
|
|
194
|
-
fields: () =>
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
207
|
+
fields: () => [
|
|
208
|
+
...Object.entries(FILTER_FIELDS).map(([value, f]) => ({ value, label: f.label, icon: f.icon })),
|
|
209
|
+
{ group: 'Custom fields', options: state.fields.filter((f) => KIND_OF_TYPE[f.type]).map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
210
|
+
],
|
|
211
|
+
kindOf,
|
|
212
|
+
opsOf: (field) => (field === 'problem' ? PROBLEM_OPS : field === 'status' ? STATUS_OPS : null),
|
|
213
|
+
choicesOf: (field) => {
|
|
214
|
+
if (field === 'client') return labels(raw?.clients);
|
|
215
|
+
if (field === 'status') return INVOICE_FILTER_STATUSES;
|
|
216
|
+
if (field === 'linked') return (raw?.linksTo ?? []).filter((s) => s.kind === 'work').map((s) => ({ value: s.id, label: s.name }));
|
|
217
|
+
if (field === 'practitioner') return labels(raw?.practitioners);
|
|
218
|
+
const custom = fieldById(fieldIdOf(field));
|
|
219
|
+
if (custom?.type === 'person') return [{ value: 'me', label: 'Me' }, ...state.people.map((p) => ({ value: p.id, label: p.name }))];
|
|
220
|
+
return (custom?.options ?? []).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name, color: o.color }));
|
|
221
|
+
},
|
|
198
222
|
view: () => state.view,
|
|
199
223
|
change: setView,
|
|
200
224
|
first: 'client',
|
|
@@ -215,7 +239,15 @@ export async function renderInvoiceList(main, ui) {
|
|
|
215
239
|
load();
|
|
216
240
|
},
|
|
217
241
|
});
|
|
218
|
-
|
|
242
|
+
// Custom fields are extra: the list still shows without them.
|
|
243
|
+
const [context, fields, values] = await Promise.all([
|
|
244
|
+
flow('/api/invoicing/context').catch(() => ({ canIssue: false })),
|
|
245
|
+
flow('/api/custom-fields').catch(() => null), flow('/api/custom-fields/values').catch(() => null),
|
|
246
|
+
]);
|
|
247
|
+
state.fields = fields?.fields ?? [];
|
|
248
|
+
state.people = fields?.people ?? [];
|
|
249
|
+
const valueKey = (job, fieldId) => `${job.sourceSystem}\u0000${job.sourceJobRef}\u0000${fieldId}`;
|
|
250
|
+
state.values = new Map((values?.values ?? []).map((v) => [valueKey(v, v.fieldId), v.value]));
|
|
219
251
|
let body = null;
|
|
220
252
|
let raw = null;
|
|
221
253
|
const openTab = (id) => { state.status = id; state.offset = 0; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
|
|
@@ -225,7 +257,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
225
257
|
// action: tick rows (the head tick takes every row shown), then Send.
|
|
226
258
|
const head = listHead('Invoices', {
|
|
227
259
|
actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
|
|
228
|
-
views: views.element, start: tabs,
|
|
260
|
+
views: views.element, start: tabs, controls, controlsAt: 'end', end: find.element, line: true, sticky: true,
|
|
229
261
|
});
|
|
230
262
|
const [title, ...top] = head.nodes;
|
|
231
263
|
const frame = listFrame(main, host);
|
|
@@ -247,17 +279,20 @@ export async function renderInvoiceList(main, ui) {
|
|
|
247
279
|
}
|
|
248
280
|
|
|
249
281
|
// A bulk step on the server: on success the ticks clear and the list loads
|
|
250
|
-
// again
|
|
251
|
-
|
|
282
|
+
// again (or `then(res)` names the tab the invoices went to, and it opens);
|
|
283
|
+
// on a failure the ticks stay, so a person can try again.
|
|
284
|
+
async function post(path, payload, done, then = () => null) {
|
|
252
285
|
try {
|
|
253
286
|
const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload) });
|
|
254
287
|
toast(...done(res));
|
|
255
288
|
state.ticked.clear();
|
|
256
289
|
seen.clear();
|
|
257
|
-
|
|
290
|
+
const tab = then(res);
|
|
291
|
+
if (tab && tab !== state.status) openTab(tab);
|
|
292
|
+
else await load();
|
|
258
293
|
alerts.refresh();
|
|
259
294
|
} catch (error) {
|
|
260
|
-
toast(error.message, 'danger');
|
|
295
|
+
toast(error.message, 'danger', { stay: true });
|
|
261
296
|
}
|
|
262
297
|
}
|
|
263
298
|
|
|
@@ -273,15 +308,25 @@ export async function renderInvoiceList(main, ui) {
|
|
|
273
308
|
status.push({
|
|
274
309
|
icon: to ? 'send' : 'circle-check-big', label: to ? `Send to ${to}` : 'Approve', primary: true,
|
|
275
310
|
title: to ? `Send to ${to}` : 'Approve',
|
|
276
|
-
run: async () => {
|
|
311
|
+
run: async (working) => {
|
|
277
312
|
const ok = await ui.dialog({
|
|
278
313
|
title: to ? `Send ${plural(ticked.length, 'invoice', 'invoices')} to ${to}?` : `Approve ${plural(ticked.length, 'invoice', 'invoices')}?`,
|
|
279
314
|
body: h('p', {}, to ? sendModeNote(to, body.sendMode, 'each one') : 'Each one gets the next number and is locked.'),
|
|
280
315
|
confirm: to ? `Send ${ticked.length}` : `Approve ${ticked.length}`,
|
|
281
316
|
});
|
|
282
317
|
if (!ok) return;
|
|
283
|
-
|
|
284
|
-
|
|
318
|
+
working(to ? 'Sending' : 'Approving');
|
|
319
|
+
// The sent ones show on Draft as Sending until the accounting
|
|
320
|
+
// system has them; the approved ones on Approved.
|
|
321
|
+
await post('send-ready', { issueDate: localDate(), invoiceIds: ticked }, (res) => {
|
|
322
|
+
const sent = to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`;
|
|
323
|
+
const notes = [
|
|
324
|
+
res.held ? `${res.held} failed a check and went to Pending.` : '',
|
|
325
|
+
res.waiting ? `${res.waiting} could not be confirmed with the work system yet: press Send again.` : '',
|
|
326
|
+
res.remaining ? `${res.remaining} more stay in Ready: tick them and press again.` : '',
|
|
327
|
+
].filter(Boolean);
|
|
328
|
+
return [`${sent}${notes.length ? ` ${notes.join(' ')}` : ''}`, res.held || res.waiting ? 'info' : 'success'];
|
|
329
|
+
}, (res) => (res.issued ? (to ? 'draft' : 'approved') : null));
|
|
285
330
|
},
|
|
286
331
|
});
|
|
287
332
|
}
|
|
@@ -289,7 +334,11 @@ export async function renderInvoiceList(main, ui) {
|
|
|
289
334
|
if (state.status === 'pending' && context.canIssue && to && failed.length) {
|
|
290
335
|
status.push({
|
|
291
336
|
icon: 'refresh-cw', label: 'Retry', primary: true, title: `Send again to ${to}`,
|
|
292
|
-
run: () =>
|
|
337
|
+
run: (working) => {
|
|
338
|
+
working('Sending');
|
|
339
|
+
return post('send-again', { invoiceIds: failed }, (res) => [`${res.queued} on the way to ${to} again.`, 'success'],
|
|
340
|
+
(res) => (res.queued ? 'draft' : null));
|
|
341
|
+
},
|
|
293
342
|
});
|
|
294
343
|
}
|
|
295
344
|
apply.push({
|
|
@@ -306,6 +355,15 @@ export async function renderInvoiceList(main, ui) {
|
|
|
306
355
|
run: () => voidAll(voidable),
|
|
307
356
|
});
|
|
308
357
|
}
|
|
358
|
+
// A draft that was never issued is deleted, not voided. Pending and Ready
|
|
359
|
+
// hold those drafts; an issued invoice on Pending is retried, not deleted.
|
|
360
|
+
const deletable = rows.filter((i) => i.status === 'draft' && i.issuedVersion === 0);
|
|
361
|
+
if (['pending', 'ready'].includes(state.status) && context.canIssue && deletable.length) {
|
|
362
|
+
danger.push({
|
|
363
|
+
icon: 'trash-2', label: 'Delete', iconOnly: true, tone: 'danger', title: 'Delete',
|
|
364
|
+
run: () => deleteAll(deletable),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
309
367
|
const all = body.invoices.map((i) => i.id);
|
|
310
368
|
const selection = [
|
|
311
369
|
{ icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
|
|
@@ -381,6 +439,29 @@ export async function renderInvoiceList(main, ui) {
|
|
|
381
439
|
alerts.refresh();
|
|
382
440
|
}
|
|
383
441
|
|
|
442
|
+
// Delete, one never-issued draft after another, as on one invoice's page.
|
|
443
|
+
async function deleteAll(rows) {
|
|
444
|
+
const ok = await ui.dialog({
|
|
445
|
+
title: `Delete ${plural(rows.length, 'invoice', 'invoices')}?`,
|
|
446
|
+
body: h('p', {}, 'A draft that was never issued is removed. Its job can be invoiced again, and the rules decide.'),
|
|
447
|
+
confirm: `Delete ${rows.length}`, tone: 'danger',
|
|
448
|
+
});
|
|
449
|
+
if (!ok) return;
|
|
450
|
+
let done = 0;
|
|
451
|
+
let error = null;
|
|
452
|
+
for (const invoice of rows) {
|
|
453
|
+
try {
|
|
454
|
+
await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
|
|
455
|
+
done += 1;
|
|
456
|
+
} catch (e) { error = e; }
|
|
457
|
+
}
|
|
458
|
+
toast(error ? `${done} of ${rows.length} deleted. ${error.message}` : `${plural(done, 'invoice', 'invoices')} deleted.`, error ? 'danger' : 'success');
|
|
459
|
+
state.ticked.clear();
|
|
460
|
+
seen.clear();
|
|
461
|
+
await load();
|
|
462
|
+
alerts.refresh();
|
|
463
|
+
}
|
|
464
|
+
|
|
384
465
|
// A tab switch shows at once: the tab is selected, the last list seen for
|
|
385
466
|
// it (if any) is drawn and dimmed, and the fresh list replaces it. Only the
|
|
386
467
|
// newest request draws, so quick clicks never show an older tab's list.
|
|
@@ -390,7 +471,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
390
471
|
const query = new URLSearchParams({
|
|
391
472
|
status: state.status, offset: String(state.offset), limit: String(state.limit), today: today(),
|
|
392
473
|
...(state.q ? { q: state.q } : {}),
|
|
393
|
-
...(filterCount(state.view,
|
|
474
|
+
...(filterCount(state.view, kindOf) ? { filter: JSON.stringify(state.view) } : {}),
|
|
394
475
|
});
|
|
395
476
|
const key = String(query);
|
|
396
477
|
const mine = ++asked;
|
|
@@ -417,6 +498,19 @@ export async function renderInvoiceList(main, ui) {
|
|
|
417
498
|
host.removeAttribute('aria-busy');
|
|
418
499
|
delete host.dataset.stale;
|
|
419
500
|
draw(answer);
|
|
501
|
+
watchSending(answer);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// While a shown invoice is still Sending, the list reads again every 15
|
|
505
|
+
// seconds, so a person sees it reach the accounting system.
|
|
506
|
+
let sendingTimer = null;
|
|
507
|
+
function watchSending(answer) {
|
|
508
|
+
clearTimeout(sendingTimer);
|
|
509
|
+
if (!answer.invoices?.some((i) => i.sending)) return;
|
|
510
|
+
const key = state.status;
|
|
511
|
+
sendingTimer = setTimeout(() => {
|
|
512
|
+
if (host.isConnected && state.status === key && !state.ticked.size) { seen.clear(); load(); }
|
|
513
|
+
}, 15_000);
|
|
420
514
|
}
|
|
421
515
|
|
|
422
516
|
function drawTabs(counts) {
|
|
@@ -447,7 +541,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
447
541
|
drawTabs(body.counts);
|
|
448
542
|
drawBar();
|
|
449
543
|
if (!body.invoices.length) {
|
|
450
|
-
host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view,
|
|
544
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, kindOf)
|
|
451
545
|
? 'No invoice in this tab passes the filter.'
|
|
452
546
|
: state.q
|
|
453
547
|
? 'No invoice matches that search.'
|
|
@@ -509,17 +603,28 @@ export async function renderInvoiceList(main, ui) {
|
|
|
509
603
|
const href = (invoice) => `/invoices/${invoice.id}`;
|
|
510
604
|
const ALL = {
|
|
511
605
|
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')) },
|
|
606
|
+
client: { kind: 'client', phone: 'title', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
|
|
513
607
|
reference: showProblem
|
|
514
608
|
? { label: 'Problem', kind: 'text', cell: problemCell }
|
|
515
609
|
: { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
|
|
516
|
-
date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) =>
|
|
610
|
+
date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.issueDate ?? i.createdAt?.slice(0, 10)) },
|
|
611
|
+
due: { kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.dueDate) },
|
|
517
612
|
total: { kind: 'total', num: true, className: 'fx-strong', cell: (i) => formatCents(i.totalCents) },
|
|
518
613
|
status: { kind: 'status', className: 'fx-status-cell', cell: statusCell },
|
|
519
614
|
linked: linked ? { kind: 'link', cell: linkCell } : null,
|
|
615
|
+
// The invoice's job (from the work system): its case, practitioner and
|
|
616
|
+
// the day it shipped.
|
|
617
|
+
case: { kind: 'case', className: 'fx-dim', cell: (i) => (i.job ? caseNumber(i.job) ?? '—' : '—') },
|
|
618
|
+
practitioner: { kind: 'text', className: 'fx-dim', cell: (i) => i.job?.practitioner ?? '—' },
|
|
619
|
+
shipped: { kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.job?.shippedOn ?? null) },
|
|
520
620
|
};
|
|
621
|
+
// Each custom field is a column too: the value of the invoice's job.
|
|
622
|
+
for (const field of state.fields) {
|
|
623
|
+
const id = fieldGroup(field.id);
|
|
624
|
+
ALL[id] = fieldColumn(h, field, { id, valueOf: (i) => (i.job ? state.values.get(valueKey(i.job, field.id)) ?? null : null), people: state.people });
|
|
625
|
+
}
|
|
521
626
|
available = Object.keys(ALL).filter((id) => ALL[id]);
|
|
522
|
-
const columns = state.columns.columns.filter((id) => ALL[id]).map((id) => ({ id, label:
|
|
627
|
+
const columns = state.columns.columns.filter((id) => ALL[id]).map((id) => ({ id, label: labelOf(id), ...ALL[id] }));
|
|
523
628
|
host.replaceChildren(heads.table({
|
|
524
629
|
className: 'fx-table--rows',
|
|
525
630
|
columns,
|
|
@@ -573,26 +678,37 @@ function saveNav(nav) {
|
|
|
573
678
|
// The bar above one invoice: Back to the list as it was, and the previous and
|
|
574
679
|
// next invoice of that list. An invoice opened from elsewhere (the job board,
|
|
575
680
|
// search, a link) steps through its own tab, read once here.
|
|
576
|
-
function navBar(ui, invoice) {
|
|
681
|
+
function navBar(ui, invoice, title = 'Invoice') {
|
|
577
682
|
const { h, go, flow } = ui;
|
|
578
683
|
const id = invoice?.id ?? null;
|
|
579
684
|
const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
685
|
+
const ends = { prev: null, next: null };
|
|
686
|
+
const goTo = (key) => { if (ends[key]) go(`/invoices/${ends[key]}`); };
|
|
687
|
+
const record = recordBar(h, {
|
|
688
|
+
title,
|
|
689
|
+
label: 'Invoices',
|
|
690
|
+
onBack: back,
|
|
691
|
+
prev: { label: 'Previous invoice', disabled: true, onclick: () => goTo('prev') },
|
|
692
|
+
next: { label: 'Next invoice', disabled: true, onclick: () => goTo('next') },
|
|
693
|
+
});
|
|
694
|
+
const { element: bar, draw } = pager(h, { label: 'Invoices', back });
|
|
590
695
|
const fill = (ids) => {
|
|
591
696
|
const at = ids.indexOf(id);
|
|
592
697
|
if (at < 0) return false;
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
698
|
+
ends.prev = ids[at - 1] ?? null;
|
|
699
|
+
ends.next = ids[at + 1] ?? null;
|
|
700
|
+
record.setSteps(
|
|
701
|
+
{ label: 'Previous invoice', disabled: !ends.prev, onclick: () => goTo('prev') },
|
|
702
|
+
{ label: 'Next invoice', disabled: !ends.next, onclick: () => goTo('next') },
|
|
703
|
+
);
|
|
704
|
+
const to = (other) => (other ? () => go(`/invoices/${other}`) : null);
|
|
705
|
+
draw({
|
|
706
|
+
count: `${at + 1} of ${ids.length}`,
|
|
707
|
+
prev: to(ends.prev),
|
|
708
|
+
next: to(ends.next),
|
|
709
|
+
prevLabel: 'Previous invoice',
|
|
710
|
+
nextLabel: 'Next invoice',
|
|
711
|
+
});
|
|
596
712
|
return true;
|
|
597
713
|
};
|
|
598
714
|
const tab = invoice?.tab ?? null;
|
|
@@ -604,7 +720,60 @@ function navBar(ui, invoice) {
|
|
|
604
720
|
try { sessionStorage.setItem('flow_invoice_tab', tab); } catch { /* Back opens the last tab */ }
|
|
605
721
|
}).catch(() => { /* Back only */ });
|
|
606
722
|
}
|
|
607
|
-
return bar;
|
|
723
|
+
return { pager: bar, record };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// A menu entry that presses a header button. Hidden and foot actions are skipped.
|
|
727
|
+
function menuEntry(button, icon, skip) {
|
|
728
|
+
if (!button || button.hidden || skip.has(button)) return null;
|
|
729
|
+
return {
|
|
730
|
+
icon,
|
|
731
|
+
text: button.textContent.trim(),
|
|
732
|
+
tone: button.classList.contains('fx-btn--danger') ? 'danger' : undefined,
|
|
733
|
+
run: () => button.click(),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// The phone record: the shared bar, the primary action in the foot, and the
|
|
738
|
+
// other actions in ⋯. Above a phone the header and the pager stay as they
|
|
739
|
+
// are; the bar and the foot are hidden by CSS. The page's h1 moves into the
|
|
740
|
+
// bar so the phone suite still finds the invoice title.
|
|
741
|
+
function mountRecord(ui, invoice, header, primaryOf, menuOf) {
|
|
742
|
+
const { h } = ui;
|
|
743
|
+
const titleNode = header.querySelector('h1');
|
|
744
|
+
const nav = navBar(ui, invoice, titleNode.textContent);
|
|
745
|
+
const slot = nav.record.querySelector('.fx-record__title');
|
|
746
|
+
const foot = h('div', { class: 'fx-record__foot', hidden: true });
|
|
747
|
+
const items = [];
|
|
748
|
+
const [menuButton, menuPanel] = rowMenu('More', items, { className: 'fx-record__menu' });
|
|
749
|
+
nav.record.setMenu(menuButton);
|
|
750
|
+
const home = header.querySelector('.fx-ph__actions');
|
|
751
|
+
// The page header is the bar's title, so the phone suite still sees it.
|
|
752
|
+
// Above a phone it stays above the invoice, and the bar is hidden.
|
|
753
|
+
const placeHeader = () => {
|
|
754
|
+
const end = nav.record.querySelector('.fx-record__end');
|
|
755
|
+
if (!isPhone()) {
|
|
756
|
+
slot.hidden = false;
|
|
757
|
+
titleNode.removeAttribute('title');
|
|
758
|
+
if (header.parentElement === nav.record) nav.pager.after(header);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
slot.hidden = true;
|
|
762
|
+
titleNode.title = titleNode.textContent;
|
|
763
|
+
if (header.parentElement !== nav.record) nav.record.insertBefore(header, end);
|
|
764
|
+
};
|
|
765
|
+
const placeMenu = () => {
|
|
766
|
+
const skip = new Set([primaryOf()].filter(Boolean));
|
|
767
|
+
items.length = 0;
|
|
768
|
+
if (isPhone()) for (const entry of menuOf(skip)) if (entry) items.push(entry);
|
|
769
|
+
menuButton.hidden = items.length === 0;
|
|
770
|
+
};
|
|
771
|
+
const placePrimary = home ? watchPhonePrimary(home, foot, primaryOf) : () => {};
|
|
772
|
+
const sync = () => { placeHeader(); placeMenu(); placePrimary(); };
|
|
773
|
+
sync();
|
|
774
|
+
queueMicrotask(sync);
|
|
775
|
+
if (typeof matchMedia === 'function') matchMedia(WIDTHS.phone).addEventListener('change', sync);
|
|
776
|
+
return { pager: nav.pager, record: nav.record, foot, menuPanel, sync };
|
|
608
777
|
}
|
|
609
778
|
|
|
610
779
|
// After a send, an Approve or a Save that makes a Pending invoice Ready, the
|
|
@@ -910,7 +1079,11 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
910
1079
|
}, 'Save');
|
|
911
1080
|
const opened = JSON.stringify(body());
|
|
912
1081
|
const changed = () => !isNew && JSON.stringify(body()) !== opened;
|
|
913
|
-
|
|
1082
|
+
let syncRecord = () => {};
|
|
1083
|
+
const watchChanges = () => {
|
|
1084
|
+
if (!keepsSave) saveButton.hidden = !changed();
|
|
1085
|
+
syncRecord();
|
|
1086
|
+
};
|
|
914
1087
|
// With an accounting system on, a ready invoice is sent there: Flow issues
|
|
915
1088
|
// it and queues it, the same as Send all ready. With none, Flow issues it
|
|
916
1089
|
// and keeps it. A Pending draft has no send: fix its problem first.
|
|
@@ -934,7 +1107,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
934
1107
|
toast(`${fresh.number} is on the way to ${to}.`, 'success');
|
|
935
1108
|
openNext(ui, invoice.id);
|
|
936
1109
|
} catch (error) {
|
|
937
|
-
toast(error.message, 'danger');
|
|
1110
|
+
toast(error.message, 'danger', { stay: true });
|
|
938
1111
|
}
|
|
939
1112
|
},
|
|
940
1113
|
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|
|
@@ -981,6 +1154,30 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
981
1154
|
// fine too.
|
|
982
1155
|
const fromJob = isNew && context.canIssue
|
|
983
1156
|
? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => pickJob(ui) }, 'From a job') : null;
|
|
1157
|
+
// A draft that was never issued is deleted. An issued invoice, including
|
|
1158
|
+
// one opened for a revision, is voided instead.
|
|
1159
|
+
const deleteButton = !isNew && context.canIssue && invoice.status === 'draft' && invoice.issuedVersion === 0
|
|
1160
|
+
? h('button', {
|
|
1161
|
+
class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
|
|
1162
|
+
onclick: async () => {
|
|
1163
|
+
const ok = await ui.dialog({
|
|
1164
|
+
title: 'Delete this invoice?',
|
|
1165
|
+
body: h('p', {}, invoice.job
|
|
1166
|
+
? 'It was never issued. The job can be invoiced again, and the rules decide.'
|
|
1167
|
+
: 'It was never issued, so it is removed.'),
|
|
1168
|
+
confirm: 'Delete', tone: 'danger',
|
|
1169
|
+
});
|
|
1170
|
+
if (!ok) return;
|
|
1171
|
+
try {
|
|
1172
|
+
await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
|
|
1173
|
+
toast('Invoice deleted.', 'success');
|
|
1174
|
+
sessionStorage.setItem(NAV_BACK, '1');
|
|
1175
|
+
go('/invoices');
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
toast(error.message, 'danger');
|
|
1178
|
+
}
|
|
1179
|
+
},
|
|
1180
|
+
}, 'Delete') : null;
|
|
984
1181
|
|
|
985
1182
|
// A draft with no problem is in Ready by itself; one with a problem is
|
|
986
1183
|
// Pending. A changed job is fixed at its source or accepted here.
|
|
@@ -1002,11 +1199,25 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1002
1199
|
if (banner) boxes[name].setAttribute('aria-describedby', banner.id);
|
|
1003
1200
|
}
|
|
1004
1201
|
|
|
1202
|
+
const previewBtn = isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.');
|
|
1203
|
+
const showAccept = Boolean(jobChange?.canAccept && context.canIssue);
|
|
1204
|
+
const header = ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
|
|
1205
|
+
actions: [fromJob, deleteButton, previewBtn, saveButton, sendButton ?? issueButton].filter(Boolean),
|
|
1206
|
+
});
|
|
1207
|
+
const primaryOf = () => (showAccept ? null : (sendButton ?? issueButton ?? (saveButton.hidden ? null : saveButton)));
|
|
1208
|
+
const chrome = mountRecord(ui, invoice, header, primaryOf, (skip) => [
|
|
1209
|
+
menuEntry(fromJob, 'briefcase', skip),
|
|
1210
|
+
menuEntry(deleteButton, 'trash-2', skip),
|
|
1211
|
+
menuEntry(previewBtn, 'eye', skip),
|
|
1212
|
+
menuEntry(saveButton, 'check', skip),
|
|
1213
|
+
menuEntry(sendButton, 'mail', skip),
|
|
1214
|
+
menuEntry(issueButton, 'check', skip),
|
|
1215
|
+
]);
|
|
1216
|
+
syncRecord = chrome.sync;
|
|
1005
1217
|
return [
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
}),
|
|
1218
|
+
chrome.record,
|
|
1219
|
+
chrome.pager,
|
|
1220
|
+
header,
|
|
1010
1221
|
draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
|
|
1011
1222
|
...problems,
|
|
1012
1223
|
h('div', { class: 'fx-invoice__grid' },
|
|
@@ -1027,6 +1238,8 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1027
1238
|
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'),
|
|
1028
1239
|
totalsHost))),
|
|
1029
1240
|
h('aside', { class: 'fx-invoice__aside' }, linkedCard(ui, invoice))),
|
|
1241
|
+
chrome.foot,
|
|
1242
|
+
chrome.menuPanel,
|
|
1030
1243
|
].map((part) => {
|
|
1031
1244
|
// Any edit on the page shows Save changes; undoing it hides it again.
|
|
1032
1245
|
for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
|
|
@@ -1103,20 +1316,27 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1103
1316
|
h('p', {}, 'The number stays used. A void invoice cannot be used again.'),
|
|
1104
1317
|
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);
|
|
1105
1318
|
};
|
|
1106
|
-
const
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise')
|
|
1112
|
-
|
|
1319
|
+
const voidButton = invoice.status === 'issued' && context.canIssue ? h('button', {
|
|
1320
|
+
class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
|
|
1321
|
+
onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
|
|
1322
|
+
}, 'Void') : null;
|
|
1323
|
+
const reviseButton = invoice.status === 'issued' && context.canIssue
|
|
1324
|
+
? h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise') : null;
|
|
1325
|
+
const preview = previewButton(ui, invoice.id);
|
|
1326
|
+
const header = h('header', { class: 'fx-ph' },
|
|
1327
|
+
h('div', { class: 'fx-ph__text' },
|
|
1328
|
+
h('h1', { class: 'fx-title' }, invoice.number),
|
|
1329
|
+
h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
|
|
1330
|
+
h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean)));
|
|
1331
|
+
const chrome = mountRecord(ui, invoice, header, () => send, (skip) => [
|
|
1332
|
+
menuEntry(voidButton, 'ban', skip),
|
|
1333
|
+
menuEntry(reviseButton, 'pencil', skip),
|
|
1334
|
+
menuEntry(preview, 'eye', skip),
|
|
1335
|
+
]);
|
|
1113
1336
|
return [
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
h('h1', { class: 'fx-title' }, invoice.number),
|
|
1118
|
-
h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
|
|
1119
|
-
h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
|
|
1337
|
+
chrome.record,
|
|
1338
|
+
chrome.pager,
|
|
1339
|
+
header,
|
|
1120
1340
|
h('div', { class: 'fx-invoice__grid' },
|
|
1121
1341
|
h('div', { class: 'fx-invoice__main' },
|
|
1122
1342
|
h('section', { class: 'fx-card fx-doc' },
|
|
@@ -1142,6 +1362,8 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1142
1362
|
h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
|
|
1143
1363
|
h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
|
|
1144
1364
|
h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
|
|
1365
|
+
chrome.foot,
|
|
1366
|
+
chrome.menuPanel,
|
|
1145
1367
|
];
|
|
1146
1368
|
}
|
|
1147
1369
|
|
package/server/ui/job-board.js
CHANGED
|
@@ -192,3 +192,13 @@ export function matches(job, q) {
|
|
|
192
192
|
return [job.caseRef, job.sourceJobRef, job.clientName, job.workClientName, job.practitioner, job.obligation?.invoiceNumber]
|
|
193
193
|
.some((value) => value && String(value).toLowerCase().includes(needle));
|
|
194
194
|
}
|
|
195
|
+
|
|
196
|
+
// How long a press on a phone card is held before Move to opens.
|
|
197
|
+
export const PHONE_HOLD_MS = 500;
|
|
198
|
+
|
|
199
|
+
// The lane showing when one lane fills the phone and the board snaps.
|
|
200
|
+
// A lane just past halfway counts as the next. `count` is how many lanes.
|
|
201
|
+
export function phoneLaneIndex(scrollLeft, laneWidth, count) {
|
|
202
|
+
if (!(laneWidth > 0) || !(count > 0)) return 0;
|
|
203
|
+
return Math.max(0, Math.min(count - 1, Math.round(scrollLeft / laneWidth)));
|
|
204
|
+
}
|
package/server/ui/job-change.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// and a recheck button that reads the job from the work system again. Flow
|
|
6
6
|
// never writes to the work system. Kept apart from
|
|
7
7
|
// invoicing.js so tests can load it without a browser.
|
|
8
|
+
import { applyPhoneRule } from './mobile.js';
|
|
8
9
|
|
|
9
10
|
// Rows shown before "Show all".
|
|
10
11
|
export const FIRST_ROWS = 5;
|
|
@@ -55,7 +56,7 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
|
|
|
55
56
|
}, `Open in ${system} ↗`)
|
|
56
57
|
: null;
|
|
57
58
|
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
|
+
? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', 'data-action': 'accept', title: `This invoice takes the Now in ${system} column.`, onclick: onAccept }, 'Accept change')
|
|
59
60
|
: null;
|
|
60
61
|
const recheck = onRecheck
|
|
61
62
|
? h('button', {
|
|
@@ -77,7 +78,7 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
|
|
|
77
78
|
const more = rows.length > FIRST_ROWS
|
|
78
79
|
? h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: (event) => { draw(true); event.currentTarget.remove(); } }, `Show all ${rows.length}`)
|
|
79
80
|
: null;
|
|
80
|
-
return h('section', { class: 'fx-change', role: 'alert' },
|
|
81
|
+
return applyPhoneRule(h('section', { class: 'fx-change', role: 'alert' },
|
|
81
82
|
h('div', { class: 'fx-change__head' },
|
|
82
83
|
h('p', {}, h('strong', {}, 'Problem.'), ' ', changeSummary(change, rows.filter((row) => row.field !== 'Total').length)),
|
|
83
84
|
h('div', { class: 'fx-change__acts' }, link, accept, recheck)),
|
|
@@ -85,7 +86,7 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
|
|
|
85
86
|
rows.length ? h('div', { class: 'fx-change__scroll' }, h('table', { class: 'fx-change__table' },
|
|
86
87
|
h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, 'Field'), h('th', { scope: 'col' }, 'Was'), h('th', { scope: 'col' }, `Now in ${system}`))),
|
|
87
88
|
body)) : null,
|
|
88
|
-
more);
|
|
89
|
+
more), 'jobChangeBanner');
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
// The banner with Accept change and the recheck wired, for the invoice page
|