@arcforgelabs/flow 2026.10.46 → 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/VISION.md +10 -10
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +56 -16
- package/server/auth/people.js +3 -1
- 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/sentry/index.js +14 -7
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +105 -25
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +235 -24
- package/server/ui/invoicing.js +254 -55
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +3 -1
- package/server/ui/jobs.js +150 -19
- package/server/ui/kit.js +198 -43
- 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/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 +18 -17
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +2 -1
- 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/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 { rowMenu } from '/assets/ui/kit.js';
|
|
14
|
+
import { isPhone, WIDTHS } from '/assets/ui/mobile.js';
|
|
15
|
+
import { recordBar, watchPhonePrimary } from '/assets/ui/sidecar.js';
|
|
13
16
|
import { columnTable, dateCell, fieldColumn, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
14
|
-
import { fieldGroup, filterCount, filterJobs, INVOICE_COLUMNS, invoiceColumnsView } 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 { 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() {
|
|
@@ -194,11 +198,27 @@ export async function renderInvoiceList(main, ui) {
|
|
|
194
198
|
const opening = views.opening();
|
|
195
199
|
if (opening) state.view = normaliseInvoicesView(opening);
|
|
196
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 }));
|
|
197
206
|
const filterMenu = viewBar(h).filterMenu({
|
|
198
|
-
fields: () =>
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
+
},
|
|
202
222
|
view: () => state.view,
|
|
203
223
|
change: setView,
|
|
204
224
|
first: 'client',
|
|
@@ -259,17 +279,20 @@ export async function renderInvoiceList(main, ui) {
|
|
|
259
279
|
}
|
|
260
280
|
|
|
261
281
|
// A bulk step on the server: on success the ticks clear and the list loads
|
|
262
|
-
// again
|
|
263
|
-
|
|
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) {
|
|
264
285
|
try {
|
|
265
286
|
const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload) });
|
|
266
287
|
toast(...done(res));
|
|
267
288
|
state.ticked.clear();
|
|
268
289
|
seen.clear();
|
|
269
|
-
|
|
290
|
+
const tab = then(res);
|
|
291
|
+
if (tab && tab !== state.status) openTab(tab);
|
|
292
|
+
else await load();
|
|
270
293
|
alerts.refresh();
|
|
271
294
|
} catch (error) {
|
|
272
|
-
toast(error.message, 'danger');
|
|
295
|
+
toast(error.message, 'danger', { stay: true });
|
|
273
296
|
}
|
|
274
297
|
}
|
|
275
298
|
|
|
@@ -285,15 +308,25 @@ export async function renderInvoiceList(main, ui) {
|
|
|
285
308
|
status.push({
|
|
286
309
|
icon: to ? 'send' : 'circle-check-big', label: to ? `Send to ${to}` : 'Approve', primary: true,
|
|
287
310
|
title: to ? `Send to ${to}` : 'Approve',
|
|
288
|
-
run: async () => {
|
|
311
|
+
run: async (working) => {
|
|
289
312
|
const ok = await ui.dialog({
|
|
290
313
|
title: to ? `Send ${plural(ticked.length, 'invoice', 'invoices')} to ${to}?` : `Approve ${plural(ticked.length, 'invoice', 'invoices')}?`,
|
|
291
314
|
body: h('p', {}, to ? sendModeNote(to, body.sendMode, 'each one') : 'Each one gets the next number and is locked.'),
|
|
292
315
|
confirm: to ? `Send ${ticked.length}` : `Approve ${ticked.length}`,
|
|
293
316
|
});
|
|
294
317
|
if (!ok) return;
|
|
295
|
-
|
|
296
|
-
|
|
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));
|
|
297
330
|
},
|
|
298
331
|
});
|
|
299
332
|
}
|
|
@@ -301,7 +334,11 @@ export async function renderInvoiceList(main, ui) {
|
|
|
301
334
|
if (state.status === 'pending' && context.canIssue && to && failed.length) {
|
|
302
335
|
status.push({
|
|
303
336
|
icon: 'refresh-cw', label: 'Retry', primary: true, title: `Send again to ${to}`,
|
|
304
|
-
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
|
+
},
|
|
305
342
|
});
|
|
306
343
|
}
|
|
307
344
|
apply.push({
|
|
@@ -318,6 +355,15 @@ export async function renderInvoiceList(main, ui) {
|
|
|
318
355
|
run: () => voidAll(voidable),
|
|
319
356
|
});
|
|
320
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
|
+
}
|
|
321
367
|
const all = body.invoices.map((i) => i.id);
|
|
322
368
|
const selection = [
|
|
323
369
|
{ icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
|
|
@@ -393,6 +439,29 @@ export async function renderInvoiceList(main, ui) {
|
|
|
393
439
|
alerts.refresh();
|
|
394
440
|
}
|
|
395
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
|
+
|
|
396
465
|
// A tab switch shows at once: the tab is selected, the last list seen for
|
|
397
466
|
// it (if any) is drawn and dimmed, and the fresh list replaces it. Only the
|
|
398
467
|
// newest request draws, so quick clicks never show an older tab's list.
|
|
@@ -402,7 +471,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
402
471
|
const query = new URLSearchParams({
|
|
403
472
|
status: state.status, offset: String(state.offset), limit: String(state.limit), today: today(),
|
|
404
473
|
...(state.q ? { q: state.q } : {}),
|
|
405
|
-
...(filterCount(state.view,
|
|
474
|
+
...(filterCount(state.view, kindOf) ? { filter: JSON.stringify(state.view) } : {}),
|
|
406
475
|
});
|
|
407
476
|
const key = String(query);
|
|
408
477
|
const mine = ++asked;
|
|
@@ -429,6 +498,19 @@ export async function renderInvoiceList(main, ui) {
|
|
|
429
498
|
host.removeAttribute('aria-busy');
|
|
430
499
|
delete host.dataset.stale;
|
|
431
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);
|
|
432
514
|
}
|
|
433
515
|
|
|
434
516
|
function drawTabs(counts) {
|
|
@@ -459,7 +541,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
459
541
|
drawTabs(body.counts);
|
|
460
542
|
drawBar();
|
|
461
543
|
if (!body.invoices.length) {
|
|
462
|
-
host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view,
|
|
544
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, kindOf)
|
|
463
545
|
? 'No invoice in this tab passes the filter.'
|
|
464
546
|
: state.q
|
|
465
547
|
? 'No invoice matches that search.'
|
|
@@ -521,7 +603,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
521
603
|
const href = (invoice) => `/invoices/${invoice.id}`;
|
|
522
604
|
const ALL = {
|
|
523
605
|
number: numbered ? { kind: 'number', cell: (i) => link(href(i), i.number ?? 'Draft') } : null,
|
|
524
|
-
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')) },
|
|
525
607
|
reference: showProblem
|
|
526
608
|
? { label: 'Problem', kind: 'text', cell: problemCell }
|
|
527
609
|
: { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
|
|
@@ -596,26 +678,37 @@ function saveNav(nav) {
|
|
|
596
678
|
// The bar above one invoice: Back to the list as it was, and the previous and
|
|
597
679
|
// next invoice of that list. An invoice opened from elsewhere (the job board,
|
|
598
680
|
// search, a link) steps through its own tab, read once here.
|
|
599
|
-
function navBar(ui, invoice) {
|
|
681
|
+
function navBar(ui, invoice, title = 'Invoice') {
|
|
600
682
|
const { h, go, flow } = ui;
|
|
601
683
|
const id = invoice?.id ?? null;
|
|
602
684
|
const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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 });
|
|
613
695
|
const fill = (ids) => {
|
|
614
696
|
const at = ids.indexOf(id);
|
|
615
697
|
if (at < 0) return false;
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
+
});
|
|
619
712
|
return true;
|
|
620
713
|
};
|
|
621
714
|
const tab = invoice?.tab ?? null;
|
|
@@ -627,7 +720,60 @@ function navBar(ui, invoice) {
|
|
|
627
720
|
try { sessionStorage.setItem('flow_invoice_tab', tab); } catch { /* Back opens the last tab */ }
|
|
628
721
|
}).catch(() => { /* Back only */ });
|
|
629
722
|
}
|
|
630
|
-
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 };
|
|
631
777
|
}
|
|
632
778
|
|
|
633
779
|
// After a send, an Approve or a Save that makes a Pending invoice Ready, the
|
|
@@ -933,7 +1079,11 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
933
1079
|
}, 'Save');
|
|
934
1080
|
const opened = JSON.stringify(body());
|
|
935
1081
|
const changed = () => !isNew && JSON.stringify(body()) !== opened;
|
|
936
|
-
|
|
1082
|
+
let syncRecord = () => {};
|
|
1083
|
+
const watchChanges = () => {
|
|
1084
|
+
if (!keepsSave) saveButton.hidden = !changed();
|
|
1085
|
+
syncRecord();
|
|
1086
|
+
};
|
|
937
1087
|
// With an accounting system on, a ready invoice is sent there: Flow issues
|
|
938
1088
|
// it and queues it, the same as Send all ready. With none, Flow issues it
|
|
939
1089
|
// and keeps it. A Pending draft has no send: fix its problem first.
|
|
@@ -957,7 +1107,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
957
1107
|
toast(`${fresh.number} is on the way to ${to}.`, 'success');
|
|
958
1108
|
openNext(ui, invoice.id);
|
|
959
1109
|
} catch (error) {
|
|
960
|
-
toast(error.message, 'danger');
|
|
1110
|
+
toast(error.message, 'danger', { stay: true });
|
|
961
1111
|
}
|
|
962
1112
|
},
|
|
963
1113
|
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|
|
@@ -1004,6 +1154,30 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1004
1154
|
// fine too.
|
|
1005
1155
|
const fromJob = isNew && context.canIssue
|
|
1006
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;
|
|
1007
1181
|
|
|
1008
1182
|
// A draft with no problem is in Ready by itself; one with a problem is
|
|
1009
1183
|
// Pending. A changed job is fixed at its source or accepted here.
|
|
@@ -1025,11 +1199,25 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1025
1199
|
if (banner) boxes[name].setAttribute('aria-describedby', banner.id);
|
|
1026
1200
|
}
|
|
1027
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;
|
|
1028
1217
|
return [
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
}),
|
|
1218
|
+
chrome.record,
|
|
1219
|
+
chrome.pager,
|
|
1220
|
+
header,
|
|
1033
1221
|
draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
|
|
1034
1222
|
...problems,
|
|
1035
1223
|
h('div', { class: 'fx-invoice__grid' },
|
|
@@ -1050,6 +1238,8 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1050
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'),
|
|
1051
1239
|
totalsHost))),
|
|
1052
1240
|
h('aside', { class: 'fx-invoice__aside' }, linkedCard(ui, invoice))),
|
|
1241
|
+
chrome.foot,
|
|
1242
|
+
chrome.menuPanel,
|
|
1053
1243
|
].map((part) => {
|
|
1054
1244
|
// Any edit on the page shows Save changes; undoing it hides it again.
|
|
1055
1245
|
for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
|
|
@@ -1126,20 +1316,27 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1126
1316
|
h('p', {}, 'The number stays used. A void invoice cannot be used again.'),
|
|
1127
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);
|
|
1128
1318
|
};
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise')
|
|
1135
|
-
|
|
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
|
+
]);
|
|
1136
1336
|
return [
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
h('h1', { class: 'fx-title' }, invoice.number),
|
|
1141
|
-
h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
|
|
1142
|
-
h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
|
|
1337
|
+
chrome.record,
|
|
1338
|
+
chrome.pager,
|
|
1339
|
+
header,
|
|
1143
1340
|
h('div', { class: 'fx-invoice__grid' },
|
|
1144
1341
|
h('div', { class: 'fx-invoice__main' },
|
|
1145
1342
|
h('section', { class: 'fx-card fx-doc' },
|
|
@@ -1165,6 +1362,8 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1165
1362
|
h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
|
|
1166
1363
|
h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
|
|
1167
1364
|
h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))))) : null)),
|
|
1365
|
+
chrome.foot,
|
|
1366
|
+
chrome.menuPanel,
|
|
1168
1367
|
];
|
|
1169
1368
|
}
|
|
1170
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
|
package/server/ui/job-view.js
CHANGED
|
@@ -304,7 +304,9 @@ export function filterJobs(jobs, view, { valueOf, kindOf = (field) => FILTER_FIE
|
|
|
304
304
|
const live = (f) => kindOf(f.field) && Object.hasOwn(OPERATORS[kindOf(f.field)], f.op) && filterIsOn(f, kindOf(f.field));
|
|
305
305
|
const groups = (view.groups ?? []).map((g) => ({ match: g.match, rows: g.filters.filter(live) })).filter((g) => g.rows.length);
|
|
306
306
|
if (!groups.length) return [...jobs];
|
|
307
|
-
|
|
307
|
+
// `late` is a job's own lateness (Jobs). Pass null when "due" is a date on
|
|
308
|
+
// the row, so Overdue means that day is before today (Invoices).
|
|
309
|
+
const pass = (job) => (f) => matchesFilter(f, valueOf(job, f.field), { today, me, kind: kindOf(f.field), late: f.field === 'due' && late ? () => late(job) : null });
|
|
308
310
|
const passGroup = (job) => (g) => (g.match === 'any' ? g.rows.some(pass(job)) : g.rows.every(pass(job)));
|
|
309
311
|
return jobs.filter((job) => (view.match === 'any' ? groups.some(passGroup(job)) : groups.every(passGroup(job))));
|
|
310
312
|
}
|