@arcforgelabs/flow 2026.10.46 → 2026.10.48-1
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 +11 -11
- package/package.json +1 -1
- package/server/api/invoicing.js +89 -22
- package/server/auth/people.js +3 -1
- package/server/auth/routes.js +4 -1
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +93 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +141 -25
- package/server/db/doctor.js +1 -1
- package/server/db/index.js +19 -0
- package/server/db/migrations/0021_flow_schema.sql +68 -0
- package/server/db/migrations/0022_contacts.sql +11 -0
- package/server/db/migrations/meta/0021_snapshot.json +4377 -0
- package/server/db/migrations/meta/0022_snapshot.json +4446 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +15 -0
- package/server/integrations/port.js +2 -0
- package/server/integrations/schema.js +3 -2
- package/server/integrations/seazona/sweep.js +11 -8
- package/server/integrations/sentry/index.js +14 -7
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/catalogue.js +82 -14
- package/server/integrations/xero/client.js +37 -0
- package/server/integrations/xero/create-contact.js +126 -0
- package/server/integrations/xero/delete-invoice.js +104 -0
- package/server/integrations/xero/drafts.js +19 -8
- package/server/integrations/xero/index.js +9 -0
- package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
- package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/routes.js +21 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +113 -28
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/contacts.js +260 -0
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +275 -24
- package/server/ui/invoicing.js +374 -70
- 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 +220 -45
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +729 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +44 -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',
|
|
@@ -230,6 +250,9 @@ export async function renderInvoiceList(main, ui) {
|
|
|
230
250
|
state.values = new Map((values?.values ?? []).map((v) => [valueKey(v, v.fieldId), v.value]));
|
|
231
251
|
let body = null;
|
|
232
252
|
let raw = null;
|
|
253
|
+
// Xero's status from Flow's copy, filled after the list draws. Delete on
|
|
254
|
+
// an issued invoice waits for this: it shows only while the copy says Draft.
|
|
255
|
+
let xeroById = new Map();
|
|
233
256
|
const openTab = (id) => { state.status = id; state.offset = 0; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
|
|
234
257
|
const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
|
|
235
258
|
// The tabs and search stay at the top while the rows scroll up under them;
|
|
@@ -259,17 +282,20 @@ export async function renderInvoiceList(main, ui) {
|
|
|
259
282
|
}
|
|
260
283
|
|
|
261
284
|
// A bulk step on the server: on success the ticks clear and the list loads
|
|
262
|
-
// again
|
|
263
|
-
|
|
285
|
+
// again (or `then(res)` names the tab the invoices went to, and it opens);
|
|
286
|
+
// on a failure the ticks stay, so a person can try again.
|
|
287
|
+
async function post(path, payload, done, then = () => null) {
|
|
264
288
|
try {
|
|
265
289
|
const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload) });
|
|
266
290
|
toast(...done(res));
|
|
267
291
|
state.ticked.clear();
|
|
268
292
|
seen.clear();
|
|
269
|
-
|
|
293
|
+
const tab = then(res);
|
|
294
|
+
if (tab && tab !== state.status) openTab(tab);
|
|
295
|
+
else await load();
|
|
270
296
|
alerts.refresh();
|
|
271
297
|
} catch (error) {
|
|
272
|
-
toast(error.message, 'danger');
|
|
298
|
+
toast(error.message, 'danger', { stay: true });
|
|
273
299
|
}
|
|
274
300
|
}
|
|
275
301
|
|
|
@@ -285,15 +311,25 @@ export async function renderInvoiceList(main, ui) {
|
|
|
285
311
|
status.push({
|
|
286
312
|
icon: to ? 'send' : 'circle-check-big', label: to ? `Send to ${to}` : 'Approve', primary: true,
|
|
287
313
|
title: to ? `Send to ${to}` : 'Approve',
|
|
288
|
-
run: async () => {
|
|
314
|
+
run: async (working) => {
|
|
289
315
|
const ok = await ui.dialog({
|
|
290
316
|
title: to ? `Send ${plural(ticked.length, 'invoice', 'invoices')} to ${to}?` : `Approve ${plural(ticked.length, 'invoice', 'invoices')}?`,
|
|
291
317
|
body: h('p', {}, to ? sendModeNote(to, body.sendMode, 'each one') : 'Each one gets the next number and is locked.'),
|
|
292
318
|
confirm: to ? `Send ${ticked.length}` : `Approve ${ticked.length}`,
|
|
293
319
|
});
|
|
294
320
|
if (!ok) return;
|
|
295
|
-
|
|
296
|
-
|
|
321
|
+
working(to ? 'Sending' : 'Approving');
|
|
322
|
+
// The sent ones show on Draft as Sending until the accounting
|
|
323
|
+
// system has them; the approved ones on Approved.
|
|
324
|
+
await post('send-ready', { issueDate: localDate(), invoiceIds: ticked }, (res) => {
|
|
325
|
+
const sent = to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`;
|
|
326
|
+
const notes = [
|
|
327
|
+
res.held ? `${res.held} failed a check and went to Pending.` : '',
|
|
328
|
+
res.waiting ? `${res.waiting} could not be confirmed with the work system yet: press Send again.` : '',
|
|
329
|
+
res.remaining ? `${res.remaining} more stay in Ready: tick them and press again.` : '',
|
|
330
|
+
].filter(Boolean);
|
|
331
|
+
return [`${sent}${notes.length ? ` ${notes.join(' ')}` : ''}`, res.held || res.waiting ? 'info' : 'success'];
|
|
332
|
+
}, (res) => (res.issued ? (to ? 'draft' : 'approved') : null));
|
|
297
333
|
},
|
|
298
334
|
});
|
|
299
335
|
}
|
|
@@ -301,7 +337,11 @@ export async function renderInvoiceList(main, ui) {
|
|
|
301
337
|
if (state.status === 'pending' && context.canIssue && to && failed.length) {
|
|
302
338
|
status.push({
|
|
303
339
|
icon: 'refresh-cw', label: 'Retry', primary: true, title: `Send again to ${to}`,
|
|
304
|
-
run: () =>
|
|
340
|
+
run: (working) => {
|
|
341
|
+
working('Sending');
|
|
342
|
+
return post('send-again', { invoiceIds: failed }, (res) => [`${res.queued} on the way to ${to} again.`, 'success'],
|
|
343
|
+
(res) => (res.queued ? 'draft' : null));
|
|
344
|
+
},
|
|
305
345
|
});
|
|
306
346
|
}
|
|
307
347
|
apply.push({
|
|
@@ -318,6 +358,21 @@ export async function renderInvoiceList(main, ui) {
|
|
|
318
358
|
run: () => voidAll(voidable),
|
|
319
359
|
});
|
|
320
360
|
}
|
|
361
|
+
// A draft that was never issued is deleted on Pending and Ready. An issued
|
|
362
|
+
// invoice is deleted only while Flow's copy of Xero says Draft. Approved
|
|
363
|
+
// or paid stays hidden. A mix runs every ticked row and reports each one.
|
|
364
|
+
const fresh = rows.filter((i) => i.status === 'draft' && i.issuedVersion === 0);
|
|
365
|
+
const issuedXero = rows.filter((i) => i.status === 'issued' && xeroById.has(i.id));
|
|
366
|
+
const xeroDraft = issuedXero.filter((i) => xeroById.get(i.id)?.state === 'draft');
|
|
367
|
+
const xeroApproved = issuedXero.filter((i) => ['approved', 'part_paid', 'paid'].includes(xeroById.get(i.id)?.state));
|
|
368
|
+
const deletable = [...fresh, ...xeroDraft, ...xeroApproved];
|
|
369
|
+
const onDraftTabs = ['pending', 'ready'].includes(state.status) && fresh.length;
|
|
370
|
+
if (context.canIssue && (onDraftTabs || xeroDraft.length)) {
|
|
371
|
+
danger.push({
|
|
372
|
+
icon: 'trash-2', label: 'Delete', iconOnly: true, tone: 'danger', title: 'Delete',
|
|
373
|
+
run: () => deleteAll(deletable, { xero: xeroDraft.length + xeroApproved.length > 0 }),
|
|
374
|
+
});
|
|
375
|
+
}
|
|
321
376
|
const all = body.invoices.map((i) => i.id);
|
|
322
377
|
const selection = [
|
|
323
378
|
{ icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
|
|
@@ -393,6 +448,52 @@ export async function renderInvoiceList(main, ui) {
|
|
|
393
448
|
alerts.refresh();
|
|
394
449
|
}
|
|
395
450
|
|
|
451
|
+
// Delete, one invoice after another, as on one invoice's page. A Xero
|
|
452
|
+
// draft reports each row: deleted, or refused with the reason.
|
|
453
|
+
async function deleteAll(rows, { xero = false } = {}) {
|
|
454
|
+
const fresh = rows.some((i) => i.status === 'draft' && i.issuedVersion === 0);
|
|
455
|
+
const ok = await ui.dialog({
|
|
456
|
+
title: `Delete ${plural(rows.length, 'invoice', 'invoices')}?`,
|
|
457
|
+
body: xero
|
|
458
|
+
? h('div', {},
|
|
459
|
+
fresh ? h('p', {}, 'A draft that was never issued is removed. Its job can be invoiced again, and the rules decide.') : null,
|
|
460
|
+
h('p', {}, 'Also deletes the draft in Xero.'))
|
|
461
|
+
: h('p', {}, 'A draft that was never issued is removed. Its job can be invoiced again, and the rules decide.'),
|
|
462
|
+
confirm: `Delete ${rows.length}`, tone: 'danger',
|
|
463
|
+
});
|
|
464
|
+
if (!ok) return;
|
|
465
|
+
if (xero) {
|
|
466
|
+
const results = [];
|
|
467
|
+
for (const invoice of rows) {
|
|
468
|
+
const name = invoice.number ?? 'Draft';
|
|
469
|
+
try {
|
|
470
|
+
await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
|
|
471
|
+
results.push(`${name} deleted.`);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
results.push(`${name} refused: ${error.message}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const refused = results.some((line) => line.includes(' refused: '));
|
|
477
|
+
// The result stays until it is closed. A success is one short line, and
|
|
478
|
+
// a refusal names each invoice, so neither should vanish on its own.
|
|
479
|
+
toast(results.join(' '), refused ? 'danger' : 'success', { stay: true });
|
|
480
|
+
} else {
|
|
481
|
+
let done = 0;
|
|
482
|
+
let error = null;
|
|
483
|
+
for (const invoice of rows) {
|
|
484
|
+
try {
|
|
485
|
+
await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
|
|
486
|
+
done += 1;
|
|
487
|
+
} catch (e) { error = e; }
|
|
488
|
+
}
|
|
489
|
+
toast(error ? `${done} of ${rows.length} deleted. ${error.message}` : `${plural(done, 'invoice', 'invoices')} deleted.`, error ? 'danger' : 'success', { stay: true });
|
|
490
|
+
}
|
|
491
|
+
state.ticked.clear();
|
|
492
|
+
seen.clear();
|
|
493
|
+
await load();
|
|
494
|
+
alerts.refresh();
|
|
495
|
+
}
|
|
496
|
+
|
|
396
497
|
// A tab switch shows at once: the tab is selected, the last list seen for
|
|
397
498
|
// it (if any) is drawn and dimmed, and the fresh list replaces it. Only the
|
|
398
499
|
// newest request draws, so quick clicks never show an older tab's list.
|
|
@@ -402,7 +503,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
402
503
|
const query = new URLSearchParams({
|
|
403
504
|
status: state.status, offset: String(state.offset), limit: String(state.limit), today: today(),
|
|
404
505
|
...(state.q ? { q: state.q } : {}),
|
|
405
|
-
...(filterCount(state.view,
|
|
506
|
+
...(filterCount(state.view, kindOf) ? { filter: JSON.stringify(state.view) } : {}),
|
|
406
507
|
});
|
|
407
508
|
const key = String(query);
|
|
408
509
|
const mine = ++asked;
|
|
@@ -429,6 +530,19 @@ export async function renderInvoiceList(main, ui) {
|
|
|
429
530
|
host.removeAttribute('aria-busy');
|
|
430
531
|
delete host.dataset.stale;
|
|
431
532
|
draw(answer);
|
|
533
|
+
watchSending(answer);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// While a shown invoice is still Sending, the list reads again every 15
|
|
537
|
+
// seconds, so a person sees it reach the accounting system.
|
|
538
|
+
let sendingTimer = null;
|
|
539
|
+
function watchSending(answer) {
|
|
540
|
+
clearTimeout(sendingTimer);
|
|
541
|
+
if (!answer.invoices?.some((i) => i.sending)) return;
|
|
542
|
+
const key = state.status;
|
|
543
|
+
sendingTimer = setTimeout(() => {
|
|
544
|
+
if (host.isConnected && state.status === key && !state.ticked.size) { seen.clear(); load(); }
|
|
545
|
+
}, 15_000);
|
|
432
546
|
}
|
|
433
547
|
|
|
434
548
|
function drawTabs(counts) {
|
|
@@ -445,6 +559,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
445
559
|
raw = answer;
|
|
446
560
|
filterMenu.draw();
|
|
447
561
|
body = answer;
|
|
562
|
+
xeroById = new Map();
|
|
448
563
|
// A page past the end (rows went to another tab): show the last one.
|
|
449
564
|
if (!answer.invoices.length && answer.total && answer.offset) {
|
|
450
565
|
state.offset = Math.max(0, Math.floor((answer.total - 1) / state.limit) * state.limit);
|
|
@@ -459,7 +574,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
459
574
|
drawTabs(body.counts);
|
|
460
575
|
drawBar();
|
|
461
576
|
if (!body.invoices.length) {
|
|
462
|
-
host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view,
|
|
577
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, kindOf)
|
|
463
578
|
? 'No invoice in this tab passes the filter.'
|
|
464
579
|
: state.q
|
|
465
580
|
? 'No invoice matches that search.'
|
|
@@ -521,7 +636,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
521
636
|
const href = (invoice) => `/invoices/${invoice.id}`;
|
|
522
637
|
const ALL = {
|
|
523
638
|
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')) },
|
|
639
|
+
client: { kind: 'client', phone: 'title', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
|
|
525
640
|
reference: showProblem
|
|
526
641
|
? { label: 'Problem', kind: 'text', cell: problemCell }
|
|
527
642
|
: { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
|
|
@@ -552,16 +667,22 @@ export async function renderInvoiceList(main, ui) {
|
|
|
552
667
|
rowCells(h, columns, invoice),
|
|
553
668
|
h('td', { class: 'fx-c-end' })))),
|
|
554
669
|
}));
|
|
555
|
-
// Issued
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
|
|
670
|
+
// Issued invoices carry Xero's status on every tab, so Delete can follow
|
|
671
|
+
// Flow's copy. A draft that is out of sync sits on Pending, which has no
|
|
672
|
+
// number column, and is still a draft there. Icons stay on the numbered
|
|
673
|
+
// tabs, where one invoice not in Xero stands out.
|
|
674
|
+
const issuedIds = body.invoices.filter((i) => i.issuedVersion > 0).map((i) => i.id);
|
|
675
|
+
if (numbered || issuedIds.length) {
|
|
559
676
|
const shownBody = body;
|
|
560
|
-
const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, body.invoices.map((i) => i.id));
|
|
677
|
+
const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, numbered ? body.invoices.map((i) => i.id) : issuedIds);
|
|
561
678
|
if (shownBody !== body) return;
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
679
|
+
xeroById = statuses;
|
|
680
|
+
if (state.ticked.size) drawBar();
|
|
681
|
+
if (numbered) {
|
|
682
|
+
for (const [invoiceId, status] of statuses) {
|
|
683
|
+
if (linked) { if (accountingSystem) statusCells.get(invoiceId)?.append(accountingIcon(ui, accountingSystem, status)); }
|
|
684
|
+
else if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
|
|
685
|
+
}
|
|
565
686
|
}
|
|
566
687
|
}
|
|
567
688
|
}
|
|
@@ -596,26 +717,37 @@ function saveNav(nav) {
|
|
|
596
717
|
// The bar above one invoice: Back to the list as it was, and the previous and
|
|
597
718
|
// next invoice of that list. An invoice opened from elsewhere (the job board,
|
|
598
719
|
// search, a link) steps through its own tab, read once here.
|
|
599
|
-
function navBar(ui, invoice) {
|
|
720
|
+
function navBar(ui, invoice, title = 'Invoice') {
|
|
600
721
|
const { h, go, flow } = ui;
|
|
601
722
|
const id = invoice?.id ?? null;
|
|
602
723
|
const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
724
|
+
const ends = { prev: null, next: null };
|
|
725
|
+
const goTo = (key) => { if (ends[key]) go(`/invoices/${ends[key]}`); };
|
|
726
|
+
const record = recordBar(h, {
|
|
727
|
+
title,
|
|
728
|
+
label: 'Invoices',
|
|
729
|
+
onBack: back,
|
|
730
|
+
prev: { label: 'Previous invoice', disabled: true, onclick: () => goTo('prev') },
|
|
731
|
+
next: { label: 'Next invoice', disabled: true, onclick: () => goTo('next') },
|
|
732
|
+
});
|
|
733
|
+
const { element: bar, draw } = pager(h, { label: 'Invoices', back });
|
|
613
734
|
const fill = (ids) => {
|
|
614
735
|
const at = ids.indexOf(id);
|
|
615
736
|
if (at < 0) return false;
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
737
|
+
ends.prev = ids[at - 1] ?? null;
|
|
738
|
+
ends.next = ids[at + 1] ?? null;
|
|
739
|
+
record.setSteps(
|
|
740
|
+
{ label: 'Previous invoice', disabled: !ends.prev, onclick: () => goTo('prev') },
|
|
741
|
+
{ label: 'Next invoice', disabled: !ends.next, onclick: () => goTo('next') },
|
|
742
|
+
);
|
|
743
|
+
const to = (other) => (other ? () => go(`/invoices/${other}`) : null);
|
|
744
|
+
draw({
|
|
745
|
+
count: `${at + 1} of ${ids.length}`,
|
|
746
|
+
prev: to(ends.prev),
|
|
747
|
+
next: to(ends.next),
|
|
748
|
+
prevLabel: 'Previous invoice',
|
|
749
|
+
nextLabel: 'Next invoice',
|
|
750
|
+
});
|
|
619
751
|
return true;
|
|
620
752
|
};
|
|
621
753
|
const tab = invoice?.tab ?? null;
|
|
@@ -627,7 +759,60 @@ function navBar(ui, invoice) {
|
|
|
627
759
|
try { sessionStorage.setItem('flow_invoice_tab', tab); } catch { /* Back opens the last tab */ }
|
|
628
760
|
}).catch(() => { /* Back only */ });
|
|
629
761
|
}
|
|
630
|
-
return bar;
|
|
762
|
+
return { pager: bar, record };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// A menu entry that presses a header button. Hidden and foot actions are skipped.
|
|
766
|
+
function menuEntry(button, icon, skip) {
|
|
767
|
+
if (!button || button.hidden || skip.has(button)) return null;
|
|
768
|
+
return {
|
|
769
|
+
icon,
|
|
770
|
+
text: button.textContent.trim(),
|
|
771
|
+
tone: button.classList.contains('fx-btn--danger') ? 'danger' : undefined,
|
|
772
|
+
run: () => button.click(),
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// The phone record: the shared bar, the primary action in the foot, and the
|
|
777
|
+
// other actions in ⋯. Above a phone the header and the pager stay as they
|
|
778
|
+
// are; the bar and the foot are hidden by CSS. The page's h1 moves into the
|
|
779
|
+
// bar so the phone suite still finds the invoice title.
|
|
780
|
+
function mountRecord(ui, invoice, header, primaryOf, menuOf) {
|
|
781
|
+
const { h } = ui;
|
|
782
|
+
const titleNode = header.querySelector('h1');
|
|
783
|
+
const nav = navBar(ui, invoice, titleNode.textContent);
|
|
784
|
+
const slot = nav.record.querySelector('.fx-record__title');
|
|
785
|
+
const foot = h('div', { class: 'fx-record__foot', hidden: true });
|
|
786
|
+
const items = [];
|
|
787
|
+
const [menuButton, menuPanel] = rowMenu('More', items, { className: 'fx-record__menu' });
|
|
788
|
+
nav.record.setMenu(menuButton);
|
|
789
|
+
const home = header.querySelector('.fx-ph__actions');
|
|
790
|
+
// The page header is the bar's title, so the phone suite still sees it.
|
|
791
|
+
// Above a phone it stays above the invoice, and the bar is hidden.
|
|
792
|
+
const placeHeader = () => {
|
|
793
|
+
const end = nav.record.querySelector('.fx-record__end');
|
|
794
|
+
if (!isPhone()) {
|
|
795
|
+
slot.hidden = false;
|
|
796
|
+
titleNode.removeAttribute('title');
|
|
797
|
+
if (header.parentElement === nav.record) nav.pager.after(header);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
slot.hidden = true;
|
|
801
|
+
titleNode.title = titleNode.textContent;
|
|
802
|
+
if (header.parentElement !== nav.record) nav.record.insertBefore(header, end);
|
|
803
|
+
};
|
|
804
|
+
const placeMenu = () => {
|
|
805
|
+
const skip = new Set([primaryOf()].filter(Boolean));
|
|
806
|
+
items.length = 0;
|
|
807
|
+
if (isPhone()) for (const entry of menuOf(skip)) if (entry) items.push(entry);
|
|
808
|
+
menuButton.hidden = items.length === 0;
|
|
809
|
+
};
|
|
810
|
+
const placePrimary = home ? watchPhonePrimary(home, foot, primaryOf) : () => {};
|
|
811
|
+
const sync = () => { placeHeader(); placeMenu(); placePrimary(); };
|
|
812
|
+
sync();
|
|
813
|
+
queueMicrotask(sync);
|
|
814
|
+
if (typeof matchMedia === 'function') matchMedia(WIDTHS.phone).addEventListener('change', sync);
|
|
815
|
+
return { pager: nav.pager, record: nav.record, foot, menuPanel, sync };
|
|
631
816
|
}
|
|
632
817
|
|
|
633
818
|
// After a send, an Approve or a Save that makes a Pending invoice Ready, the
|
|
@@ -933,7 +1118,11 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
933
1118
|
}, 'Save');
|
|
934
1119
|
const opened = JSON.stringify(body());
|
|
935
1120
|
const changed = () => !isNew && JSON.stringify(body()) !== opened;
|
|
936
|
-
|
|
1121
|
+
let syncRecord = () => {};
|
|
1122
|
+
const watchChanges = () => {
|
|
1123
|
+
if (!keepsSave) saveButton.hidden = !changed();
|
|
1124
|
+
syncRecord();
|
|
1125
|
+
};
|
|
937
1126
|
// With an accounting system on, a ready invoice is sent there: Flow issues
|
|
938
1127
|
// it and queues it, the same as Send all ready. With none, Flow issues it
|
|
939
1128
|
// and keeps it. A Pending draft has no send: fix its problem first.
|
|
@@ -957,7 +1146,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
957
1146
|
toast(`${fresh.number} is on the way to ${to}.`, 'success');
|
|
958
1147
|
openNext(ui, invoice.id);
|
|
959
1148
|
} catch (error) {
|
|
960
|
-
toast(error.message, 'danger');
|
|
1149
|
+
toast(error.message, 'danger', { stay: true });
|
|
961
1150
|
}
|
|
962
1151
|
},
|
|
963
1152
|
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|
|
@@ -1004,6 +1193,11 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1004
1193
|
// fine too.
|
|
1005
1194
|
const fromJob = isNew && context.canIssue
|
|
1006
1195
|
? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => pickJob(ui) }, 'From a job') : null;
|
|
1196
|
+
// A draft that was never issued is deleted. An issued invoice, including
|
|
1197
|
+
// one opened for a revision, is voided instead, unless its Xero copy is a
|
|
1198
|
+
// draft: that Delete is added on the issued page after the copy loads.
|
|
1199
|
+
const deleteButton = !isNew && context.canIssue && invoice.status === 'draft' && invoice.issuedVersion === 0
|
|
1200
|
+
? deleteControl(ui, invoice, { xeroDraft: false }) : null;
|
|
1007
1201
|
|
|
1008
1202
|
// A draft with no problem is in Ready by itself; one with a problem is
|
|
1009
1203
|
// Pending. A changed job is fixed at its source or accepted here.
|
|
@@ -1011,13 +1205,44 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1011
1205
|
// The job changed after the invoice was made: its own banner shows what
|
|
1012
1206
|
// changed. Other problems stay one line.
|
|
1013
1207
|
const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
|
|
1208
|
+
const offer = otherProblems.find((p) => p.code === 'xero_contact_missing' && p.offerCreate);
|
|
1209
|
+
const problemNotice = otherProblems.length
|
|
1210
|
+
? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.')
|
|
1211
|
+
: null;
|
|
1212
|
+
const organisationId = context.organisations[0]?.id;
|
|
1213
|
+
const createClient = offer && context.canIssue && invoice?.contact?.id && organisationId
|
|
1214
|
+
? h('button', {
|
|
1215
|
+
class: 'fx-btn fx-btn--primary', type: 'button', title: 'Create this client in Xero',
|
|
1216
|
+
onclick: async (event) => {
|
|
1217
|
+
const button = event.currentTarget;
|
|
1218
|
+
const name = invoice.contact.name;
|
|
1219
|
+
const confirmed = await ui.dialog({
|
|
1220
|
+
title: `Create ${name} in Xero?`,
|
|
1221
|
+
body: h('p', {}, 'Flow adds this client to Xero from the contact.'),
|
|
1222
|
+
confirm: 'Create',
|
|
1223
|
+
});
|
|
1224
|
+
if (!confirmed) return;
|
|
1225
|
+
button.disabled = true;
|
|
1226
|
+
try {
|
|
1227
|
+
await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: invoice.contact.id }) });
|
|
1228
|
+
toast(`${name} is in Xero.`, 'success');
|
|
1229
|
+
const body = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
1230
|
+
redraw(body.invoice);
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
toast(error.message, error.status === 429 ? 'info' : 'danger');
|
|
1233
|
+
if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
|
|
1234
|
+
else button.disabled = false;
|
|
1235
|
+
}
|
|
1236
|
+
},
|
|
1237
|
+
}, 'Create')
|
|
1238
|
+
: null;
|
|
1014
1239
|
const problems = [
|
|
1015
1240
|
// Accepting changes the draft for the business: owners and admins only.
|
|
1016
1241
|
jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
|
|
1017
|
-
|
|
1242
|
+
problemNotice ? (createClient ? h('div', { class: 'fx-offer' }, problemNotice, createClient) : problemNotice) : null,
|
|
1018
1243
|
];
|
|
1019
1244
|
// A red field names the banner as its reason (for a screen reader).
|
|
1020
|
-
const banner =
|
|
1245
|
+
const banner = problemNotice;
|
|
1021
1246
|
if (banner) banner.id = 'fx-invoice-problem';
|
|
1022
1247
|
for (const name of failed) {
|
|
1023
1248
|
if (!boxes[name]) continue;
|
|
@@ -1025,11 +1250,25 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1025
1250
|
if (banner) boxes[name].setAttribute('aria-describedby', banner.id);
|
|
1026
1251
|
}
|
|
1027
1252
|
|
|
1253
|
+
const previewBtn = isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.');
|
|
1254
|
+
const showAccept = Boolean(jobChange?.canAccept && context.canIssue);
|
|
1255
|
+
const header = ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
|
|
1256
|
+
actions: [fromJob, deleteButton, previewBtn, saveButton, sendButton ?? issueButton].filter(Boolean),
|
|
1257
|
+
});
|
|
1258
|
+
const primaryOf = () => (showAccept ? null : (sendButton ?? issueButton ?? (saveButton.hidden ? null : saveButton)));
|
|
1259
|
+
const chrome = mountRecord(ui, invoice, header, primaryOf, (skip) => [
|
|
1260
|
+
menuEntry(fromJob, 'briefcase', skip),
|
|
1261
|
+
menuEntry(deleteButton, 'trash-2', skip),
|
|
1262
|
+
menuEntry(previewBtn, 'eye', skip),
|
|
1263
|
+
menuEntry(saveButton, 'check', skip),
|
|
1264
|
+
menuEntry(sendButton, 'mail', skip),
|
|
1265
|
+
menuEntry(issueButton, 'check', skip),
|
|
1266
|
+
]);
|
|
1267
|
+
syncRecord = chrome.sync;
|
|
1028
1268
|
return [
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
}),
|
|
1269
|
+
chrome.record,
|
|
1270
|
+
chrome.pager,
|
|
1271
|
+
header,
|
|
1033
1272
|
draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
|
|
1034
1273
|
...problems,
|
|
1035
1274
|
h('div', { class: 'fx-invoice__grid' },
|
|
@@ -1050,6 +1289,8 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1050
1289
|
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
1290
|
totalsHost))),
|
|
1052
1291
|
h('aside', { class: 'fx-invoice__aside' }, linkedCard(ui, invoice))),
|
|
1292
|
+
chrome.foot,
|
|
1293
|
+
chrome.menuPanel,
|
|
1053
1294
|
].map((part) => {
|
|
1054
1295
|
// Any edit on the page shows Save changes; undoing it hides it again.
|
|
1055
1296
|
for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
|
|
@@ -1090,6 +1331,35 @@ function previewButton(ui, invoiceId, title = 'Shows the invoice as a PDF.') {
|
|
|
1090
1331
|
}, 'Preview');
|
|
1091
1332
|
}
|
|
1092
1333
|
|
|
1334
|
+
// Delete asks first. A Xero draft says so; a never-issued draft says the job
|
|
1335
|
+
// can be invoiced again. Then the list opens.
|
|
1336
|
+
function deleteControl(ui, invoice, { xeroDraft }) {
|
|
1337
|
+
const { h, flow, toast, go } = ui;
|
|
1338
|
+
return h('button', {
|
|
1339
|
+
class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button', 'data-delete': '',
|
|
1340
|
+
onclick: async () => {
|
|
1341
|
+
const ok = await ui.dialog({
|
|
1342
|
+
title: 'Delete this invoice?',
|
|
1343
|
+
body: h('p', {}, xeroDraft
|
|
1344
|
+
? 'Also deletes the draft in Xero.'
|
|
1345
|
+
: invoice.job
|
|
1346
|
+
? 'It was never issued. The job can be invoiced again, and the rules decide.'
|
|
1347
|
+
: 'It was never issued, so it is removed.'),
|
|
1348
|
+
confirm: 'Delete', tone: 'danger',
|
|
1349
|
+
});
|
|
1350
|
+
if (!ok) return;
|
|
1351
|
+
try {
|
|
1352
|
+
await flow(`/api/invoicing/invoices/${invoice.id}`, { method: 'DELETE' });
|
|
1353
|
+
toast('Invoice deleted.', 'success', { stay: true });
|
|
1354
|
+
sessionStorage.setItem(NAV_BACK, '1');
|
|
1355
|
+
go('/invoices');
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
toast(error.message, 'danger', { stay: true });
|
|
1358
|
+
}
|
|
1359
|
+
},
|
|
1360
|
+
}, 'Delete');
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1093
1363
|
// An issued or void invoice: the document as sent, and what can happen next.
|
|
1094
1364
|
function issuedView(invoice, ui, context, redraw) {
|
|
1095
1365
|
const { h, flow, toast } = ui;
|
|
@@ -1111,14 +1381,28 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1111
1381
|
const linkedHost = h('div', {}, linkedCard(ui, invoice) ?? '');
|
|
1112
1382
|
let xeroStatus = null;
|
|
1113
1383
|
const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
|
|
1384
|
+
let actionBar = null;
|
|
1385
|
+
let deleteButton = null;
|
|
1386
|
+
let recordSync = () => {};
|
|
1114
1387
|
const loadXero = async () => {
|
|
1115
1388
|
if (!context.xero) return;
|
|
1116
1389
|
xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
|
|
1117
1390
|
xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
|
|
1118
1391
|
linkedHost.replaceChildren(linkedCard(ui, invoice, xeroStatus) ?? '');
|
|
1119
1392
|
if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
|
|
1393
|
+
// Delete only while Flow's copy says Draft. Approved and paid stay on Void.
|
|
1394
|
+
const canDelete = invoice.status === 'issued' && context.canIssue && xeroStatus?.state === 'draft';
|
|
1395
|
+
const remove = actionBar?.querySelector('[data-delete]');
|
|
1396
|
+
if (canDelete && !remove && actionBar) {
|
|
1397
|
+
deleteButton = deleteControl(ui, invoice, { xeroDraft: true });
|
|
1398
|
+
actionBar.prepend(deleteButton);
|
|
1399
|
+
}
|
|
1400
|
+
if (!canDelete && remove) {
|
|
1401
|
+
remove.remove();
|
|
1402
|
+
deleteButton = null;
|
|
1403
|
+
}
|
|
1404
|
+
recordSync();
|
|
1120
1405
|
};
|
|
1121
|
-
loadXero();
|
|
1122
1406
|
// Voiding in Flow never changes Xero: say so while Xero still holds it live.
|
|
1123
1407
|
const voidBody = () => {
|
|
1124
1408
|
const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
|
|
@@ -1126,20 +1410,40 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1126
1410
|
h('p', {}, 'The number stays used. A void invoice cannot be used again.'),
|
|
1127
1411
|
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
1412
|
};
|
|
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
|
-
|
|
1413
|
+
const voidButton = invoice.status === 'issued' && context.canIssue ? h('button', {
|
|
1414
|
+
class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
|
|
1415
|
+
onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
|
|
1416
|
+
}, 'Void') : null;
|
|
1417
|
+
const reviseButton = invoice.status === 'issued' && context.canIssue
|
|
1418
|
+
? h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise') : null;
|
|
1419
|
+
const preview = previewButton(ui, invoice.id);
|
|
1420
|
+
actionBar = h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean));
|
|
1421
|
+
const header = h('header', { class: 'fx-ph' },
|
|
1422
|
+
h('div', { class: 'fx-ph__text' },
|
|
1423
|
+
h('h1', { class: 'fx-title' }, invoice.number),
|
|
1424
|
+
h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
|
|
1425
|
+
actionBar);
|
|
1426
|
+
const chrome = mountRecord(ui, invoice, header, () => send, (skip) => [
|
|
1427
|
+
menuEntry(deleteButton, 'trash-2', skip),
|
|
1428
|
+
menuEntry(voidButton, 'ban', skip),
|
|
1429
|
+
menuEntry(reviseButton, 'pencil', skip),
|
|
1430
|
+
menuEntry(preview, 'eye', skip),
|
|
1431
|
+
]);
|
|
1432
|
+
recordSync = chrome.sync;
|
|
1433
|
+
loadXero();
|
|
1434
|
+
const history = [
|
|
1435
|
+
...(invoice.versions ?? []).map((v) => h('li', {},
|
|
1436
|
+
h('span', { class: 'fx-strong' }, v.version === 1 ? 'Version 1' : `Revised (version ${v.version})`),
|
|
1437
|
+
h('span', { class: 'fx-dim' }, new Date(v.issuedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))),
|
|
1438
|
+
// Void, including a delete that removed the Xero draft, sits on the same list.
|
|
1439
|
+
...(invoice.status === 'void' ? [h('li', {},
|
|
1440
|
+
h('span', { class: 'fx-strong' }, 'Void'),
|
|
1441
|
+
h('span', { class: 'fx-dim' }, new Date(invoice.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })))] : []),
|
|
1442
|
+
];
|
|
1136
1443
|
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)),
|
|
1444
|
+
chrome.record,
|
|
1445
|
+
chrome.pager,
|
|
1446
|
+
header,
|
|
1143
1447
|
h('div', { class: 'fx-invoice__grid' },
|
|
1144
1448
|
h('div', { class: 'fx-invoice__main' },
|
|
1145
1449
|
h('section', { class: 'fx-card fx-doc' },
|
|
@@ -1160,11 +1464,11 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1160
1464
|
h('aside', { class: 'fx-invoice__aside' },
|
|
1161
1465
|
linkedHost,
|
|
1162
1466
|
xeroHost,
|
|
1163
|
-
|
|
1467
|
+
history.length ? h('section', { class: 'fx-card fx-side-card' },
|
|
1164
1468
|
h('h3', { class: 'fx-label' }, 'History'),
|
|
1165
|
-
h('ol', { class: 'fx-timeline' },
|
|
1166
|
-
|
|
1167
|
-
|
|
1469
|
+
h('ol', { class: 'fx-timeline' }, history)) : null)),
|
|
1470
|
+
chrome.foot,
|
|
1471
|
+
chrome.menuPanel,
|
|
1168
1472
|
];
|
|
1169
1473
|
}
|
|
1170
1474
|
|