@arcforgelabs/flow 2026.10.30 → 2026.10.32
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 +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +125 -7
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/automations.js +17 -3
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoices.js +2 -2
- package/server/core/invoicing-rules.js +26 -1
- package/server/core/job-board.js +25 -3
- package/server/core/ledger.js +3 -2
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +76 -4
- package/server/core/price-list.js +348 -0
- package/server/core/references.js +99 -0
- package/server/core/sending.js +11 -5
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/0016_invoice_reference.sql +1 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/0016_snapshot.json +3861 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +46 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +21 -1
- package/server/integrations/seazona/feed.js +31 -2
- package/server/integrations/seazona/governor.js +50 -122
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +46 -1
- package/server/integrations/seazona/lanes.js +187 -51
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
- package/server/integrations/seazona/patients.js +30 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/schema.js +94 -0
- package/server/integrations/seazona/settings.js +14 -3
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +4 -0
- package/server/integrations/xero/client.js +39 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +131 -17
- package/server/integrations/xero/index.js +38 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +27 -10
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +55 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/integrations/xero/settings.js +3 -0
- package/server/integrations/xero/webhooks.js +311 -0
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +63 -21
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +78 -9
- package/server/ui/invoice-notes.js +26 -0
- package/server/ui/invoicing-settings.js +4 -2
- package/server/ui/invoicing.js +241 -49
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/price-list.js +247 -0
- package/server/ui/routes.js +1 -1
package/server/ui/invoicing.js
CHANGED
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
// are a preview with the same rule (GST 10% per line, rounded half away from
|
|
6
6
|
// zero, server/core/money.js). Data comes from /api/invoicing
|
|
7
7
|
// (server/api/invoicing.js). app.js passes in its shared pieces as `ui`.
|
|
8
|
+
import { alertStrip } from '/assets/ui/alerts.js';
|
|
9
|
+
import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
|
|
8
10
|
|
|
9
11
|
const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
|
|
10
12
|
const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
|
|
11
13
|
// Drafts (to check), Problem and Ready are views (server/core/sending.js):
|
|
12
|
-
// every draft is in one of them. A person marks a checked draft ready;
|
|
13
|
-
//
|
|
14
|
+
// every draft is in one of them. A person marks a checked draft ready; a
|
|
15
|
+
// saved change keeps it there. An issued invoice that failed to send is a
|
|
14
16
|
// problem.
|
|
15
|
-
|
|
17
|
+
// Ready first: it opens by default, as the drafts most often wait there to send.
|
|
18
|
+
const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Issued'], ['void', 'Void']];
|
|
16
19
|
const VIEW_LABEL = { problem: 'Problem', ready: 'Ready', sending: 'Sending', to_check: 'Draft' };
|
|
17
20
|
const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_check: 'info' };
|
|
18
21
|
const EMPTY = {
|
|
@@ -26,6 +29,31 @@ const TICKS = { to_check: true, ready: true };
|
|
|
26
29
|
const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
|
|
27
30
|
export const formatCents = (cents) => money.format((cents ?? 0) / 100);
|
|
28
31
|
|
|
32
|
+
// Xero's status of an issued invoice, from Flow's copy of Xero (issue #132).
|
|
33
|
+
// A finding (a difference, or a void on one side only) shows as Out of sync.
|
|
34
|
+
const XERO_TONE = {
|
|
35
|
+
not_sent: 'mute', sending: 'info', not_copied: 'info', draft: 'info',
|
|
36
|
+
approved: 'success', part_paid: 'success', paid: 'success', voided: 'mute', deleted: 'mute',
|
|
37
|
+
};
|
|
38
|
+
const FIELD_LABEL = { contact: 'Client', subtotal: 'Subtotal', tax: 'GST', total: 'Total', 'line count': 'Lines', items: 'Items', date: 'Date' };
|
|
39
|
+
|
|
40
|
+
export function xeroTag(ui, status) {
|
|
41
|
+
const { h } = ui;
|
|
42
|
+
if (!status) return null;
|
|
43
|
+
return status.finding
|
|
44
|
+
? h('span', { class: 'fx-status', 'data-tone': 'danger', title: status.message }, 'Out of sync with Xero')
|
|
45
|
+
: h('span', { class: 'fx-status', 'data-tone': XERO_TONE[status.state] ?? 'mute' }, status.label);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const showField = (field, value) => (['subtotal', 'tax', 'total'].includes(field) && Number.isFinite(value) ? formatCents(value) : String(value ?? '—'));
|
|
49
|
+
|
|
50
|
+
// Xero's status for some invoices, or an empty map while Xero is off.
|
|
51
|
+
async function xeroStatuses(ui, organisationId, invoiceIds) {
|
|
52
|
+
if (!organisationId || !invoiceIds.length) return new Map();
|
|
53
|
+
const res = await ui.flow('/api/xero/invoices/status', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds }) }).catch(() => null);
|
|
54
|
+
return new Map((res?.invoices ?? []).map((s) => [s.invoiceId, s]));
|
|
55
|
+
}
|
|
56
|
+
|
|
29
57
|
function gstCents(lineAmountCents) {
|
|
30
58
|
const sign = Math.sign(lineAmountCents);
|
|
31
59
|
const abs = Math.abs(lineAmountCents) * 1000;
|
|
@@ -53,17 +81,24 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
|
|
|
53
81
|
export async function renderInvoiceList(main, ui) {
|
|
54
82
|
const { h, flow, pageHeader, notice, go, toast } = ui;
|
|
55
83
|
const saved = sessionStorage.getItem('flow_invoice_tab');
|
|
56
|
-
|
|
84
|
+
// Back from an invoice comes here with the tab, search and scroll it left.
|
|
85
|
+
const back = readNav();
|
|
86
|
+
const returning = sessionStorage.getItem(NAV_BACK) === '1';
|
|
87
|
+
sessionStorage.removeItem(NAV_BACK);
|
|
88
|
+
const state = { status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set() };
|
|
57
89
|
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
|
|
58
|
-
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice' });
|
|
90
|
+
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice', value: state.q });
|
|
59
91
|
const bar = h('div', { class: 'fx-sendbar', hidden: true });
|
|
60
92
|
const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
61
93
|
const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
|
|
62
94
|
let body = null;
|
|
95
|
+
const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
|
|
96
|
+
const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
|
|
63
97
|
main.append(
|
|
64
98
|
pageHeader('Invoices', {
|
|
65
99
|
actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
|
|
66
100
|
}),
|
|
101
|
+
alerts.element,
|
|
67
102
|
h('div', { class: 'fx-toolbar' }, tabs, search),
|
|
68
103
|
bar,
|
|
69
104
|
host);
|
|
@@ -85,6 +120,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
85
120
|
toast(...done(res));
|
|
86
121
|
state.ticked.clear();
|
|
87
122
|
await load();
|
|
123
|
+
alerts.refresh();
|
|
88
124
|
} catch (error) {
|
|
89
125
|
toast(error.message, 'danger');
|
|
90
126
|
el.disabled = false;
|
|
@@ -107,10 +143,10 @@ export async function renderInvoiceList(main, ui) {
|
|
|
107
143
|
: [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
|
|
108
144
|
}
|
|
109
145
|
} else if (state.status === 'ready') {
|
|
110
|
-
parts.push(h('p', { class: 'fx-dim' }, to
|
|
146
|
+
parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
|
|
111
147
|
const buttons = [];
|
|
112
148
|
if (ticked.length) {
|
|
113
|
-
buttons.push(action(`
|
|
149
|
+
buttons.push(action(`Move ${ticked.length} to Drafts`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
|
|
114
150
|
(res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
|
|
115
151
|
}
|
|
116
152
|
if (context.canIssue && body.counts.ready > 0) {
|
|
@@ -144,7 +180,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
144
180
|
role: 'tab',
|
|
145
181
|
'aria-selected': String(state.status === id),
|
|
146
182
|
...(id === 'problem' && body.counts?.problem ? { 'data-tone': 'danger' } : {}),
|
|
147
|
-
onclick: () =>
|
|
183
|
+
onclick: () => openTab(id),
|
|
148
184
|
}, label, h('span', { class: 'fx-tabs__count' }, String(body.counts?.[id] ?? 0)))));
|
|
149
185
|
drawBar();
|
|
150
186
|
if (!body.invoices.length) {
|
|
@@ -153,6 +189,9 @@ export async function renderInvoiceList(main, ui) {
|
|
|
153
189
|
: EMPTY[state.status] ?? `No ${STATUS_LABEL[state.status].toLowerCase()} invoices yet.`));
|
|
154
190
|
return;
|
|
155
191
|
}
|
|
192
|
+
// The invoice page steps through this list, in this order (navBar).
|
|
193
|
+
saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
|
|
194
|
+
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
|
|
156
195
|
const ticks = TICKS[state.status];
|
|
157
196
|
const showProblem = state.status === 'problem';
|
|
158
197
|
const created = ['to_check', 'problem', 'ready'].includes(state.status);
|
|
@@ -175,6 +214,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
175
214
|
drawBar();
|
|
176
215
|
},
|
|
177
216
|
});
|
|
217
|
+
const statusCells = new Map();
|
|
178
218
|
host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
|
|
179
219
|
h('thead', {}, h('tr', {},
|
|
180
220
|
ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
|
|
@@ -184,21 +224,79 @@ export async function renderInvoiceList(main, ui) {
|
|
|
184
224
|
const href = `/invoices/${invoice.id}`;
|
|
185
225
|
const first = invoice.problems?.[0];
|
|
186
226
|
const view = invoice.status === 'void' ? null : invoice.view;
|
|
187
|
-
return h('tr', { tabindex: '0', onclick: () =>
|
|
227
|
+
return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
|
|
188
228
|
ticks ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)) : null,
|
|
189
|
-
h('td', {}, h('a', { class: 'fx-strong', href, onclick:
|
|
229
|
+
h('td', {}, h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, invoice.number ?? 'Draft')),
|
|
190
230
|
h('td', {}, invoice.contactName ?? '—'),
|
|
191
231
|
showProblem
|
|
192
232
|
? h('td', { class: 'fx-problem' }, first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1} more`) : null)
|
|
193
|
-
: h('td', { class: 'fx-dim' }, invoice.
|
|
233
|
+
: h('td', { class: 'fx-dim' }, invoice.reference ?? '—'),
|
|
194
234
|
h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
|
|
195
235
|
h('td', { class: 'fx-num fx-strong' }, formatCents(invoice.totalCents)),
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
236
|
+
(() => {
|
|
237
|
+
const cell = h('td', { class: 'fx-status-cell' }, view
|
|
238
|
+
? h('span', { class: 'fx-status', 'data-tone': VIEW_TONE[view] }, VIEW_LABEL[view])
|
|
239
|
+
: h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status]));
|
|
240
|
+
statusCells.set(invoice.id, cell);
|
|
241
|
+
return cell;
|
|
242
|
+
})());
|
|
199
243
|
}))));
|
|
244
|
+
// Issued and void invoices also show Xero's status, from Flow's copy.
|
|
245
|
+
if (['issued', 'void'].includes(state.status)) {
|
|
246
|
+
const shownBody = body;
|
|
247
|
+
const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, body.invoices.map((i) => i.id));
|
|
248
|
+
if (shownBody !== body) return;
|
|
249
|
+
for (const [invoiceId, status] of statuses) {
|
|
250
|
+
if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
200
253
|
}
|
|
201
254
|
await load();
|
|
255
|
+
if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The list an invoice was opened from: its tab, search, invoice ids in order,
|
|
259
|
+
// and scroll. The invoice page's bar steps through it and goes back to it.
|
|
260
|
+
const NAV = 'flow_invoice_nav';
|
|
261
|
+
const NAV_BACK = 'flow_invoice_back';
|
|
262
|
+
function readNav() {
|
|
263
|
+
try { return JSON.parse(sessionStorage.getItem(NAV) ?? 'null'); } catch { return null; }
|
|
264
|
+
}
|
|
265
|
+
function saveNav(nav) {
|
|
266
|
+
try { sessionStorage.setItem(NAV, JSON.stringify(nav)); } catch { /* the bar then shows Back only */ }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The bar above one invoice: Back to the list as it was, and the previous and
|
|
270
|
+
// next invoice of that list.
|
|
271
|
+
function navBar(ui, id) {
|
|
272
|
+
const { h, go } = ui;
|
|
273
|
+
const nav = readNav();
|
|
274
|
+
const ids = nav?.ids ?? [];
|
|
275
|
+
const at = ids.indexOf(id);
|
|
276
|
+
const back = () => { sessionStorage.setItem(NAV_BACK, '1'); go('/invoices'); };
|
|
277
|
+
const step = (to, label, path) => {
|
|
278
|
+
const button = h('button', {
|
|
279
|
+
class: 'fx-pager__step', type: 'button', disabled: !to, 'aria-label': label, title: label,
|
|
280
|
+
onclick: () => go(`/invoices/${to}`),
|
|
281
|
+
});
|
|
282
|
+
button.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
283
|
+
return button;
|
|
284
|
+
};
|
|
285
|
+
return h('nav', { class: 'fx-pager', 'aria-label': 'Invoices' },
|
|
286
|
+
h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back'),
|
|
287
|
+
at >= 0 ? h('span', { class: 'fx-pager__count' }, `${at + 1} of ${ids.length}`) : null,
|
|
288
|
+
at >= 0 ? h('span', { class: 'fx-pager__steps' }, step(ids[at - 1], 'Previous invoice', 'M14.5 6l-6 6 6 6'), step(ids[at + 1], 'Next invoice', 'M9.5 6l6 6-6 6')) : null);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// After a send, the next invoice of the list opens; at its end, the list.
|
|
292
|
+
function openNext(ui, id) {
|
|
293
|
+
const nav = readNav();
|
|
294
|
+
const ids = nav?.ids ?? [];
|
|
295
|
+
const at = ids.indexOf(id);
|
|
296
|
+
const next = at >= 0 ? (ids[at + 1] ?? ids[at - 1]) : null;
|
|
297
|
+
if (at >= 0) saveNav({ ...nav, ids: ids.filter((other) => other !== id) });
|
|
298
|
+
if (next) ui.go(`/invoices/${next}`);
|
|
299
|
+
else { sessionStorage.setItem(NAV_BACK, '1'); ui.go('/invoices'); }
|
|
202
300
|
}
|
|
203
301
|
|
|
204
302
|
// ── One invoice ────────────────────────────────────────────────────────────
|
|
@@ -225,17 +323,11 @@ export async function renderInvoice(main, ui, id) {
|
|
|
225
323
|
const draw = (current) => {
|
|
226
324
|
host.replaceChildren(...(current?.status && current.status !== 'draft'
|
|
227
325
|
? issuedView(current, ui, context, draw)
|
|
228
|
-
: draftView(current, ui, context, draw)));
|
|
326
|
+
: draftView(current, ui, context, draw)).filter(Boolean));
|
|
229
327
|
};
|
|
230
328
|
draw(invoice);
|
|
231
329
|
}
|
|
232
330
|
|
|
233
|
-
function crumbs(ui, label) {
|
|
234
|
-
const { h, go } = ui;
|
|
235
|
-
return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
|
|
236
|
-
h('a', { href: '/invoices', onclick: go }, 'Invoices'), h('span', { 'aria-hidden': 'true' }, '/'), h('span', {}, label));
|
|
237
|
-
}
|
|
238
|
-
|
|
239
331
|
function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
|
|
240
332
|
const { h } = ui;
|
|
241
333
|
return h('dl', { class: 'fx-totals' },
|
|
@@ -244,13 +336,13 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
|
|
|
244
336
|
h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
|
|
245
337
|
}
|
|
246
338
|
|
|
339
|
+
// The job an invoice bills. The case id stays on the record, not on screen.
|
|
247
340
|
function jobCard(ui, job) {
|
|
248
341
|
const { h } = ui;
|
|
249
342
|
if (!job) return null;
|
|
250
343
|
return h('section', { class: 'fx-card fx-side-card' },
|
|
251
344
|
h('h3', { class: 'fx-label' }, 'Job'),
|
|
252
345
|
h('dl', { class: 'fx-facts' },
|
|
253
|
-
h('dt', {}, 'Case'), h('dd', {}, job.caseRef ?? job.sourceJobRef),
|
|
254
346
|
job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
|
|
255
347
|
job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null,
|
|
256
348
|
h('dt', {}, 'From'), h('dd', {}, job.sourceSystem === 'seazona' ? 'Seazona' : job.sourceSystem)));
|
|
@@ -411,24 +503,56 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
411
503
|
}
|
|
412
504
|
};
|
|
413
505
|
|
|
506
|
+
// Save shows only when there is something to save: a new invoice, or a
|
|
507
|
+
// change since the page opened. Saving keeps the invoice where it is.
|
|
414
508
|
const saveButton = h('button', {
|
|
415
|
-
class: 'fx-btn', type: 'button',
|
|
509
|
+
class: 'fx-btn', type: 'button', hidden: !isNew,
|
|
416
510
|
onclick: async () => {
|
|
417
511
|
const saved = await save();
|
|
418
512
|
if (!saved) return;
|
|
419
|
-
toast('Draft saved.', 'success');
|
|
513
|
+
toast(isNew ? 'Draft saved.' : 'Changes saved.', 'success');
|
|
420
514
|
if (isNew) go(`/invoices/${saved.id}`, { replace: true });
|
|
421
515
|
else redraw(saved);
|
|
422
516
|
},
|
|
423
|
-
}, 'Save draft');
|
|
424
|
-
const
|
|
517
|
+
}, isNew ? 'Save draft' : 'Save changes');
|
|
518
|
+
const opened = JSON.stringify(body());
|
|
519
|
+
const changed = () => !isNew && JSON.stringify(body()) !== opened;
|
|
520
|
+
const watchChanges = () => { if (!isNew) saveButton.hidden = !changed(); };
|
|
521
|
+
// With an accounting system on, a ready invoice is sent there: Flow issues
|
|
522
|
+
// it and queues it, the same as Send all ready. With none, Flow issues it
|
|
523
|
+
// and keeps it. A draft that is not ready has no send: mark it ready first.
|
|
524
|
+
const to = invoice?.sendsTo ?? null;
|
|
525
|
+
const ready = invoice?.view === 'ready';
|
|
526
|
+
const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
|
|
527
|
+
const emails = invoice?.sendMode?.mode === 'approve_and_email';
|
|
528
|
+
const sendButton = context.canIssue && to && ready ? h('button', {
|
|
529
|
+
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
530
|
+
onclick: async () => {
|
|
531
|
+
if (changed() && !(await save())) return;
|
|
532
|
+
const ok = await ui.dialog({
|
|
533
|
+
title: `Send this invoice to ${to}?`,
|
|
534
|
+
body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
|
|
535
|
+
confirm: emails ? 'Send and email' : 'Send',
|
|
536
|
+
});
|
|
537
|
+
if (!ok) return;
|
|
538
|
+
try {
|
|
539
|
+
await flow('/api/invoicing/invoices/send-ready', { method: 'POST', body: JSON.stringify({ issueDate: localDate(), invoiceIds: [invoice.id] }) });
|
|
540
|
+
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
541
|
+
toast(`${fresh.number} is on the way to ${to}.`, 'success');
|
|
542
|
+
openNext(ui, invoice.id);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
toast(error.message, 'danger');
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|
|
548
|
+
const issueButton = context.canIssue && !to ? h('button', {
|
|
425
549
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
426
550
|
onclick: async () => {
|
|
427
551
|
const saved = await save();
|
|
428
552
|
if (!saved) return;
|
|
429
553
|
const ok = await ui.dialog({
|
|
430
554
|
title: 'Issue this invoice?',
|
|
431
|
-
body: h('p', {}, `It gets the next number (${
|
|
555
|
+
body: h('p', {}, `It gets the next number (${nextNumber}). Flow keeps it exactly as issued. To change it later, revise it.`),
|
|
432
556
|
confirm: 'Issue',
|
|
433
557
|
});
|
|
434
558
|
if (!ok) { if (isNew) go(`/invoices/${saved.id}`, { replace: true }); else redraw(saved); return; }
|
|
@@ -444,40 +568,51 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
444
568
|
}, 'Issue invoice') : null;
|
|
445
569
|
|
|
446
570
|
// Mark ready: the draft is saved, then Flow checks it; one with a problem
|
|
447
|
-
// stays a draft.
|
|
448
|
-
const
|
|
449
|
-
const readyButton = isNew ? null : h('button', {
|
|
571
|
+
// stays a draft. Move to Drafts, on the Status card, undoes it.
|
|
572
|
+
const markReadyButton = isNew || ready ? null : h('button', {
|
|
450
573
|
class: 'fx-btn', type: 'button',
|
|
451
574
|
onclick: async () => {
|
|
452
|
-
|
|
453
|
-
if (!current) return;
|
|
575
|
+
if (!(await save())) return;
|
|
454
576
|
try {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
} else {
|
|
459
|
-
const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
|
|
460
|
-
if (res.marked) toast('Ready to send.', 'success');
|
|
461
|
-
else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
|
|
462
|
-
}
|
|
577
|
+
const res = await flow('/api/invoicing/invoices/mark-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
|
|
578
|
+
if (res.marked) toast('Ready to send.', 'success');
|
|
579
|
+
else toast(res.refused[0]?.problems.map((p) => p.message).join(' ') ?? 'It has a problem.', 'danger');
|
|
463
580
|
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
464
581
|
redraw(fresh);
|
|
465
582
|
} catch (error) {
|
|
466
583
|
toast(error.message, 'danger');
|
|
467
584
|
}
|
|
468
585
|
},
|
|
469
|
-
},
|
|
586
|
+
}, 'Mark ready');
|
|
587
|
+
// A ready invoice can go back to Drafts. It is a status change, so it sits
|
|
588
|
+
// with the status, not with the page's main buttons.
|
|
589
|
+
const statusCard = ready ? h('section', { class: 'fx-card fx-side-card' },
|
|
590
|
+
h('h3', { class: 'fx-label' }, 'Status'),
|
|
591
|
+
h('p', {}, 'Ready to send.'),
|
|
592
|
+
h('button', {
|
|
593
|
+
class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button',
|
|
594
|
+
onclick: async () => {
|
|
595
|
+
try {
|
|
596
|
+
await flow('/api/invoicing/invoices/mark-not-ready', { method: 'POST', body: JSON.stringify({ invoiceIds: [invoice.id] }) });
|
|
597
|
+
toast('Moved to Drafts.', 'success');
|
|
598
|
+
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
599
|
+
redraw(fresh);
|
|
600
|
+
} catch (error) {
|
|
601
|
+
toast(error.message, 'danger');
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
}, 'Move to Drafts')) : null;
|
|
470
605
|
const problems = invoice?.problems?.length
|
|
471
606
|
? notice(invoice.problems.map((p) => p.message).join(' '), 'danger', 'Problem.')
|
|
472
607
|
: null;
|
|
473
608
|
|
|
474
609
|
return [
|
|
475
|
-
|
|
610
|
+
navBar(ui, invoice?.id ?? null),
|
|
476
611
|
h('header', { class: 'fx-ph' },
|
|
477
612
|
h('div', { class: 'fx-ph__text' },
|
|
478
613
|
h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
|
|
479
|
-
h('p', { class: 'fx-ph__sub' },
|
|
480
|
-
h('div', { class: 'fx-ph__actions' }, saveButton,
|
|
614
|
+
h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
|
|
615
|
+
h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, sendButton ?? issueButton)),
|
|
481
616
|
problems,
|
|
482
617
|
h('div', { class: 'fx-invoice__grid' },
|
|
483
618
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -496,8 +631,12 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
496
631
|
h('div', { class: 'fx-lines__foot' },
|
|
497
632
|
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'),
|
|
498
633
|
totalsHost))),
|
|
499
|
-
h('aside', { class: 'fx-invoice__aside' }, jobCard(ui, invoice?.job))),
|
|
500
|
-
]
|
|
634
|
+
h('aside', { class: 'fx-invoice__aside' }, statusCard, jobCard(ui, invoice?.job))),
|
|
635
|
+
].map((part) => {
|
|
636
|
+
// Any edit on the page shows Save changes; undoing it hides it again.
|
|
637
|
+
for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
|
|
638
|
+
return part;
|
|
639
|
+
});
|
|
501
640
|
}
|
|
502
641
|
|
|
503
642
|
// An issued or void invoice: the document as sent, and what can happen next.
|
|
@@ -516,16 +655,32 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
516
655
|
toast(error.message, 'danger');
|
|
517
656
|
}
|
|
518
657
|
};
|
|
658
|
+
const organisationId = context.organisations?.[0]?.id;
|
|
659
|
+
const xeroHost = h('div');
|
|
660
|
+
let xeroStatus = null;
|
|
661
|
+
const loadXero = async () => {
|
|
662
|
+
if (!context.xero) return;
|
|
663
|
+
xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
|
|
664
|
+
xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
|
|
665
|
+
};
|
|
666
|
+
loadXero();
|
|
667
|
+
// Voiding in Flow never changes Xero: say so while Xero still holds it live.
|
|
668
|
+
const voidBody = () => {
|
|
669
|
+
const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
|
|
670
|
+
return h('div', {},
|
|
671
|
+
h('p', {}, 'The number stays used. A void invoice cannot be issued again.'),
|
|
672
|
+
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);
|
|
673
|
+
};
|
|
519
674
|
const actions = invoice.status === 'issued' && context.canIssue ? [
|
|
520
675
|
h('button', {
|
|
521
676
|
class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
|
|
522
|
-
onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body:
|
|
677
|
+
onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
|
|
523
678
|
}, 'Void'),
|
|
524
679
|
h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
|
|
525
680
|
sendToXero(invoice, ui, context),
|
|
526
681
|
] : [];
|
|
527
682
|
return [
|
|
528
|
-
|
|
683
|
+
navBar(ui, invoice.id),
|
|
529
684
|
h('header', { class: 'fx-ph' },
|
|
530
685
|
h('div', { class: 'fx-ph__text' },
|
|
531
686
|
h('div', { class: 'fx-row' },
|
|
@@ -551,6 +706,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
551
706
|
h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
|
|
552
707
|
h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
|
|
553
708
|
h('aside', { class: 'fx-invoice__aside' },
|
|
709
|
+
xeroHost,
|
|
554
710
|
jobCard(ui, invoice.job),
|
|
555
711
|
invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
|
|
556
712
|
h('h3', { class: 'fx-label' }, 'History'),
|
|
@@ -560,8 +716,44 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
560
716
|
];
|
|
561
717
|
}
|
|
562
718
|
|
|
719
|
+
// Xero's copy of this invoice: its status, any difference from what Flow
|
|
720
|
+
// sent, and Mark resolved, which reads Xero's changes and checks again. It
|
|
721
|
+
// never writes to Xero: a person fixes the difference where they choose.
|
|
722
|
+
function xeroCard(invoice, ui, context, status, reload) {
|
|
723
|
+
const { h, flow, toast } = ui;
|
|
724
|
+
if (!status || (status.state === 'not_sent' && !status.finding)) return null;
|
|
725
|
+
const rows = status.differences.map((d) => h('tr', {},
|
|
726
|
+
h('td', {}, FIELD_LABEL[d.field] ?? d.field),
|
|
727
|
+
h('td', { class: 'fx-num' }, showField(d.field, d.flow)),
|
|
728
|
+
h('td', { class: 'fx-num' }, showField(d.field, d.xero))));
|
|
729
|
+
const resolve = status.finding && context.canIssue ? h('button', {
|
|
730
|
+
class: 'fx-btn fx-btn--sm', type: 'button',
|
|
731
|
+
onclick: async () => {
|
|
732
|
+
resolve.disabled = true;
|
|
733
|
+
try {
|
|
734
|
+
const res = await flow(`/api/xero/invoices/${invoice.id}/recheck`, { method: 'POST', body: JSON.stringify({ organisationId: context.organisations[0].id }) });
|
|
735
|
+
toast(res.resolved ? 'Flow and Xero agree now.' : 'Still different. Fix it in Xero or in Flow, then try again.', res.resolved ? 'success' : 'danger');
|
|
736
|
+
await reload();
|
|
737
|
+
} catch (error) {
|
|
738
|
+
toast(error.message, 'danger');
|
|
739
|
+
resolve.disabled = false;
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
}, 'Mark resolved') : null;
|
|
743
|
+
return h('section', { class: 'fx-card fx-side-card' },
|
|
744
|
+
h('h3', { class: 'fx-label' }, 'Xero'),
|
|
745
|
+
h('div', { class: 'fx-row' }, xeroTag(ui, status), status.xero?.number ? h('span', { class: 'fx-dim' }, status.xero.number) : null),
|
|
746
|
+
status.finding ? h('p', { class: 'fx-problem' }, status.message) : null,
|
|
747
|
+
rows.length ? h('table', { class: 'fx-table' },
|
|
748
|
+
h('thead', {}, h('tr', {}, h('th', {}, ''), h('th', { class: 'fx-num' }, 'Flow sent'), h('th', { class: 'fx-num' }, 'Xero now'))),
|
|
749
|
+
h('tbody', {}, rows)) : null,
|
|
750
|
+
h('div', { class: 'fx-row' },
|
|
751
|
+
status.xero?.url ? h('a', { class: 'fx-btn fx-btn--sm fx-btn--ghost', href: status.xero.url, target: '_blank', rel: 'noopener' }, 'Open in Xero') : null,
|
|
752
|
+
resolve));
|
|
753
|
+
}
|
|
754
|
+
|
|
563
755
|
// Send to Xero (POST /api/xero/invoices/push). The customer's send mode
|
|
564
|
-
// (Settings,
|
|
756
|
+
// (Settings, Invoice rules) decides what Xero gets: a draft that a person approves
|
|
565
757
|
// there, or an approved invoice that Xero emails to the customer.
|
|
566
758
|
function sendToXero(invoice, ui, context) {
|
|
567
759
|
const { h, flow, toast } = ui;
|
|
@@ -28,8 +28,9 @@ export function lineMappingSection(ui) {
|
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
const waiting = labels.filter((l) => l.unmapped > 0);
|
|
31
|
+
const listLink = row('Price list', 'Your codes and prices, and their links.', h('a', { class: 'fx-btn fx-btn--ghost fx-btn--sm', href: '/price-list', onclick: (event) => ui.go(event) }, 'Open'));
|
|
31
32
|
if (!waiting.length) {
|
|
32
|
-
body.replaceChildren(row('Every line has a price code', 'Lines from Seazona find their price item by themselves.', h('span', { class: 'fx-dim' }, '')));
|
|
33
|
+
body.replaceChildren(row('Every line has a price code', 'Lines from Seazona find their price item by themselves.', h('span', { class: 'fx-dim' }, '')), listLink);
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
36
|
const options = [h('option', { value: '' }, 'Choose a price item…'),
|
|
@@ -53,7 +54,7 @@ export function lineMappingSection(ui) {
|
|
|
53
54
|
});
|
|
54
55
|
const lines = label.unmapped === 1 ? '1 line waits' : `${label.unmapped} lines wait`;
|
|
55
56
|
return row(label.sample, `${money(label.unitAmountCents)} · ${lines}`, select);
|
|
56
|
-
}));
|
|
57
|
+
}), listLink);
|
|
57
58
|
}
|
|
58
59
|
load();
|
|
59
60
|
return section;
|