@arcforgelabs/flow 2026.10.32 → 2026.10.34
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 +9 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +114 -0
- package/server/api/invoicing.js +58 -25
- package/server/api/work-moves.js +80 -0
- package/server/app.js +16 -0
- package/server/core/custom-fields.js +300 -0
- package/server/core/invoicing-rules.js +50 -4
- package/server/core/job-board.js +17 -4
- package/server/core/ledger.js +242 -6
- package/server/core/pipeline.js +29 -21
- package/server/core/read-before-act.js +96 -0
- package/server/core/reconcile.js +350 -0
- package/server/core/sending.js +44 -6
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0017_sync_basis.sql +1 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/meta/0017_snapshot.json +3867 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +62 -0
- package/server/index.js +11 -1
- package/server/integrations/port.js +10 -1
- package/server/integrations/seazona/act.js +48 -0
- package/server/integrations/seazona/feed.js +70 -50
- package/server/integrations/seazona/governor.js +28 -2
- package/server/integrations/seazona/index.js +107 -4
- package/server/integrations/seazona/lanes.js +81 -8
- package/server/integrations/seazona/ledger.js +14 -9
- package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
- package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
- package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
- package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
- package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
- package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
- package/server/integrations/seazona/reconcile.js +40 -0
- package/server/integrations/seazona/scheduler.js +32 -4
- package/server/integrations/seazona/schema.js +47 -0
- package/server/integrations/seazona/settings.js +26 -0
- package/server/integrations/seazona/stage-moves.js +135 -0
- package/server/integrations/seazona/store.js +24 -2
- package/server/integrations/seazona/sweep.js +574 -0
- package/server/integrations/seazona/tables.js +3 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/integrations/xero/index.js +4 -0
- package/server/integrations/xero/webhooks.js +35 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +40 -2
- package/server/ui/custom-fields.js +190 -0
- package/server/ui/flow.css +57 -9
- package/server/ui/invoicing.js +184 -22
- package/server/ui/job-board.js +2 -1
- package/server/ui/jobs.js +22 -2
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
package/server/ui/invoicing.js
CHANGED
|
@@ -119,6 +119,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
119
119
|
const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload()) });
|
|
120
120
|
toast(...done(res));
|
|
121
121
|
state.ticked.clear();
|
|
122
|
+
seen.clear();
|
|
122
123
|
await load();
|
|
123
124
|
alerts.refresh();
|
|
124
125
|
} catch (error) {
|
|
@@ -165,23 +166,56 @@ export async function renderInvoiceList(main, ui) {
|
|
|
165
166
|
bar.hidden = !parts.length;
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
// A tab switch shows at once: the tab is selected, the last list seen for
|
|
170
|
+
// it (if any) is drawn and dimmed, and the fresh list replaces it. Only the
|
|
171
|
+
// newest request draws, so quick clicks never show an older tab's list.
|
|
172
|
+
const seen = new Map();
|
|
173
|
+
let asked = 0;
|
|
168
174
|
async function load() {
|
|
175
|
+
const query = new URLSearchParams({ status: state.status, ...(state.q ? { q: state.q } : {}) });
|
|
176
|
+
const key = String(query);
|
|
177
|
+
const mine = ++asked;
|
|
178
|
+
if (seen.has(key)) {
|
|
179
|
+
delete host.dataset.stale;
|
|
180
|
+
draw(seen.get(key));
|
|
181
|
+
} else if (body) {
|
|
182
|
+
drawTabs(body.counts);
|
|
183
|
+
bar.hidden = true;
|
|
184
|
+
host.dataset.stale = '';
|
|
185
|
+
}
|
|
186
|
+
host.setAttribute('aria-busy', 'true');
|
|
187
|
+
let answer;
|
|
169
188
|
try {
|
|
170
|
-
|
|
171
|
-
body = await flow(`/api/invoicing/invoices?${query}`);
|
|
189
|
+
answer = await flow(`/api/invoicing/invoices?${query}`);
|
|
172
190
|
} catch (error) {
|
|
191
|
+
if (mine !== asked) return;
|
|
192
|
+
host.removeAttribute('aria-busy');
|
|
193
|
+
delete host.dataset.stale;
|
|
173
194
|
host.replaceChildren(notice(error.message, 'danger'));
|
|
174
195
|
return;
|
|
175
196
|
}
|
|
176
|
-
|
|
177
|
-
|
|
197
|
+
if (mine !== asked) return;
|
|
198
|
+
seen.set(key, answer);
|
|
199
|
+
host.removeAttribute('aria-busy');
|
|
200
|
+
delete host.dataset.stale;
|
|
201
|
+
draw(answer);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function drawTabs(counts) {
|
|
178
205
|
tabs.replaceChildren(...TABS.map(([id, label]) => h('button', {
|
|
179
206
|
type: 'button',
|
|
180
207
|
role: 'tab',
|
|
181
208
|
'aria-selected': String(state.status === id),
|
|
182
|
-
...(id === 'problem' &&
|
|
183
|
-
onclick: () => openTab(id),
|
|
184
|
-
}, label, h('span', { class: 'fx-tabs__count' }, String(
|
|
209
|
+
...(id === 'problem' && counts?.problem ? { 'data-tone': 'danger' } : {}),
|
|
210
|
+
onclick: () => { if (state.status !== id) openTab(id); },
|
|
211
|
+
}, label, h('span', { class: 'fx-tabs__count' }, String(counts?.[id] ?? 0)))));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function draw(answer) {
|
|
215
|
+
body = answer;
|
|
216
|
+
const shown = new Set(body.invoices.map((i) => i.id));
|
|
217
|
+
for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
|
|
218
|
+
drawTabs(body.counts);
|
|
185
219
|
drawBar();
|
|
186
220
|
if (!body.invoices.length) {
|
|
187
221
|
host.replaceChildren(h('p', { class: 'fx-empty' }, state.q
|
|
@@ -192,9 +226,13 @@ export async function renderInvoiceList(main, ui) {
|
|
|
192
226
|
// The invoice page steps through this list, in this order (navBar).
|
|
193
227
|
saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
|
|
194
228
|
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
|
|
229
|
+
const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
|
|
195
230
|
const ticks = TICKS[state.status];
|
|
196
231
|
const showProblem = state.status === 'problem';
|
|
197
232
|
const created = ['to_check', 'problem', 'ready'].includes(state.status);
|
|
233
|
+
// An invoice gets its number when it is sent or issued, so only the
|
|
234
|
+
// Issued and Void tabs show a Number column; the others open on Client.
|
|
235
|
+
const numbered = !created;
|
|
198
236
|
const boxes = [];
|
|
199
237
|
const tickBox = (invoice) => {
|
|
200
238
|
const box = h('input', {
|
|
@@ -218,7 +256,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
218
256
|
host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
|
|
219
257
|
h('thead', {}, h('tr', {},
|
|
220
258
|
ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
|
|
221
|
-
h('th', {}, 'Number'), h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
|
|
259
|
+
numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
|
|
222
260
|
h('th', {}, created ? 'Created' : 'Issued'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'))),
|
|
223
261
|
h('tbody', {}, body.invoices.map((invoice) => {
|
|
224
262
|
const href = `/invoices/${invoice.id}`;
|
|
@@ -226,8 +264,8 @@ export async function renderInvoiceList(main, ui) {
|
|
|
226
264
|
const view = invoice.status === 'void' ? null : invoice.view;
|
|
227
265
|
return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
|
|
228
266
|
ticks ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)) : null,
|
|
229
|
-
h('td', {},
|
|
230
|
-
h('td', {}, invoice.contactName ?? '—'),
|
|
267
|
+
numbered ? h('td', {}, link(href, invoice.number ?? 'Draft')) : null,
|
|
268
|
+
h('td', {}, numbered ? (invoice.contactName ?? '—') : link(href, invoice.contactName ?? 'No client')),
|
|
231
269
|
showProblem
|
|
232
270
|
? h('td', { class: 'fx-problem' }, first?.message ?? '—', invoice.problems.length > 1 ? h('span', { class: 'fx-dim' }, ` +${invoice.problems.length - 1} more`) : null)
|
|
233
271
|
: h('td', { class: 'fx-dim' }, invoice.reference ?? '—'),
|
|
@@ -289,12 +327,12 @@ function navBar(ui, id) {
|
|
|
289
327
|
}
|
|
290
328
|
|
|
291
329
|
// After a send, the next invoice of the list opens; at its end, the list.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
330
|
+
// The sent invoice stays in the bar's list, so the count holds (2 of 35) and
|
|
331
|
+
// Previous opens it again, now sent. Back reloads the list without it.
|
|
332
|
+
export function openNext(ui, id) {
|
|
333
|
+
const ids = readNav()?.ids ?? [];
|
|
295
334
|
const at = ids.indexOf(id);
|
|
296
|
-
const next = at >= 0 ?
|
|
297
|
-
if (at >= 0) saveNav({ ...nav, ids: ids.filter((other) => other !== id) });
|
|
335
|
+
const next = at >= 0 ? ids[at + 1] : null;
|
|
298
336
|
if (next) ui.go(`/invoices/${next}`);
|
|
299
337
|
else { sessionStorage.setItem(NAV_BACK, '1'); ui.go('/invoices'); }
|
|
300
338
|
}
|
|
@@ -355,6 +393,8 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
355
393
|
const lines = (invoice?.lines ?? []).map((line) => ({ ...line }));
|
|
356
394
|
if (!lines.length) lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null });
|
|
357
395
|
let contact = invoice?.contact ?? null;
|
|
396
|
+
// Issue invoice waits for a client and a due date (syncIssue, below).
|
|
397
|
+
let onContact = () => {};
|
|
358
398
|
|
|
359
399
|
// Client: type to find a customer.
|
|
360
400
|
const clientInput = h('input', { class: 'fx-input', type: 'text', autocomplete: 'off', placeholder: 'Type a client name', value: contact?.name ?? '', 'aria-label': 'Client' });
|
|
@@ -376,6 +416,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
376
416
|
contact = c;
|
|
377
417
|
clientInput.value = c.name;
|
|
378
418
|
clientList.hidden = true;
|
|
419
|
+
onContact();
|
|
379
420
|
},
|
|
380
421
|
}, c.name, c.email ? h('span', { class: 'fx-dim' }, c.email) : null)) : [h('p', { class: 'fx-dim fx-suggest__none' }, 'No client by that name.')]));
|
|
381
422
|
clientList.hidden = false;
|
|
@@ -386,7 +427,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
386
427
|
});
|
|
387
428
|
clientInput.addEventListener('blur', () => { setTimeout(() => { clientList.hidden = true; }, 120); });
|
|
388
429
|
|
|
389
|
-
const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: '
|
|
430
|
+
const reference = h('input', { class: 'fx-input', type: 'text', value: invoice?.reference ?? '', placeholder: 'Practitioner - Patient' });
|
|
390
431
|
const dueDate = h('input', { class: 'fx-input', type: 'date', value: invoice?.dueDate ?? '' });
|
|
391
432
|
|
|
392
433
|
// Lines.
|
|
@@ -521,7 +562,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
521
562
|
// With an accounting system on, a ready invoice is sent there: Flow issues
|
|
522
563
|
// it and queues it, the same as Send all ready. With none, Flow issues it
|
|
523
564
|
// and keeps it. A draft that is not ready has no send: mark it ready first.
|
|
524
|
-
const to = invoice?.sendsTo ?? null;
|
|
565
|
+
const to = invoice?.sendsTo ?? context.sendsTo ?? null;
|
|
525
566
|
const ready = invoice?.view === 'ready';
|
|
526
567
|
const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
|
|
527
568
|
const emails = invoice?.sendMode?.mode === 'approve_and_email';
|
|
@@ -566,6 +607,28 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
566
607
|
}
|
|
567
608
|
},
|
|
568
609
|
}, 'Issue invoice') : null;
|
|
610
|
+
// Issue needs a client and a due date: until both are set it is greyed.
|
|
611
|
+
const syncIssue = () => {
|
|
612
|
+
if (!issueButton) return;
|
|
613
|
+
const missing = [contact ? null : 'a client', dueDate.value ? null : 'a due date'].filter(Boolean);
|
|
614
|
+
issueButton.disabled = missing.length > 0;
|
|
615
|
+
issueButton.title = missing.length ? `Choose ${missing.join(' and ')} first.` : '';
|
|
616
|
+
};
|
|
617
|
+
onContact = syncIssue;
|
|
618
|
+
clientInput.addEventListener('input', syncIssue);
|
|
619
|
+
dueDate.addEventListener('input', syncIssue);
|
|
620
|
+
dueDate.addEventListener('change', syncIssue);
|
|
621
|
+
syncIssue();
|
|
622
|
+
|
|
623
|
+
// A new invoice can start from a job that has no invoice yet: a quiet
|
|
624
|
+
// strip above the form says so. Flow drafts it the way the invoicing rules
|
|
625
|
+
// do (client, Reference, lines) and links it to the job. An invoice with
|
|
626
|
+
// no job is fine too.
|
|
627
|
+
const fromJob = isNew && context.canIssue ? h('section', { class: 'fx-fromjob' },
|
|
628
|
+
h('div', { class: 'fx-fromjob__text' },
|
|
629
|
+
h('span', { class: 'fx-strong' }, 'Billing a job?'),
|
|
630
|
+
h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
|
|
631
|
+
h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
|
|
569
632
|
|
|
570
633
|
// Mark ready: the draft is saved, then Flow checks it; one with a problem
|
|
571
634
|
// stays a draft. Move to Drafts, on the Status card, undoes it.
|
|
@@ -612,8 +675,9 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
612
675
|
h('div', { class: 'fx-ph__text' },
|
|
613
676
|
h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Draft invoice'),
|
|
614
677
|
h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
|
|
615
|
-
h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, sendButton ?? issueButton)),
|
|
678
|
+
h('div', { class: 'fx-ph__actions' }, saveButton, markReadyButton, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), sendButton ?? issueButton)),
|
|
616
679
|
problems,
|
|
680
|
+
fromJob,
|
|
617
681
|
h('div', { class: 'fx-invoice__grid' },
|
|
618
682
|
h('div', { class: 'fx-invoice__main' },
|
|
619
683
|
h('section', { class: 'fx-card fx-invoice__head' },
|
|
@@ -639,6 +703,39 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
639
703
|
});
|
|
640
704
|
}
|
|
641
705
|
|
|
706
|
+
// Preview: the invoice as a PDF in a new tab (GET /api/invoicing/invoices/:id/pdf).
|
|
707
|
+
// The tab opens at once, because a browser blocks a tab opened after a wait,
|
|
708
|
+
// and shows the PDF when it is ready. If Flow cannot make it, the tab closes
|
|
709
|
+
// and a toast says why.
|
|
710
|
+
function previewButton(ui, invoiceId, title = 'Shows the invoice as a PDF.') {
|
|
711
|
+
const { h, toast } = ui;
|
|
712
|
+
return h('button', {
|
|
713
|
+
class: 'fx-btn', type: 'button', title,
|
|
714
|
+
onclick: async () => {
|
|
715
|
+
const tab = window.open('', '_blank');
|
|
716
|
+
if (tab) {
|
|
717
|
+
tab.document.title = 'Preparing PDF';
|
|
718
|
+
tab.document.body.textContent = 'Preparing PDF…';
|
|
719
|
+
}
|
|
720
|
+
try {
|
|
721
|
+
const token = await window.Clerk.session?.getToken();
|
|
722
|
+
const res = await fetch(`/api/invoicing/invoices/${invoiceId}/pdf`, { headers: { Authorization: `Bearer ${token}` } });
|
|
723
|
+
if (!res.ok) {
|
|
724
|
+
const body = await res.json().catch(() => ({}));
|
|
725
|
+
throw new Error(body.error || `HTTP ${res.status}`);
|
|
726
|
+
}
|
|
727
|
+
const url = URL.createObjectURL(await res.blob());
|
|
728
|
+
if (tab) tab.location.href = url;
|
|
729
|
+
else window.location.assign(url);
|
|
730
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
731
|
+
} catch (error) {
|
|
732
|
+
tab?.close();
|
|
733
|
+
toast(`The PDF could not be made. ${error.message}`, 'danger');
|
|
734
|
+
}
|
|
735
|
+
},
|
|
736
|
+
}, 'Preview');
|
|
737
|
+
}
|
|
738
|
+
|
|
642
739
|
// An issued or void invoice: the document as sent, and what can happen next.
|
|
643
740
|
function issuedView(invoice, ui, context, redraw) {
|
|
644
741
|
const { h, flow, toast } = ui;
|
|
@@ -658,10 +755,12 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
658
755
|
const organisationId = context.organisations?.[0]?.id;
|
|
659
756
|
const xeroHost = h('div');
|
|
660
757
|
let xeroStatus = null;
|
|
758
|
+
const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
|
|
661
759
|
const loadXero = async () => {
|
|
662
760
|
if (!context.xero) return;
|
|
663
761
|
xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
|
|
664
762
|
xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
|
|
763
|
+
if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
|
|
665
764
|
};
|
|
666
765
|
loadXero();
|
|
667
766
|
// Voiding in Flow never changes Xero: say so while Xero still holds it live.
|
|
@@ -677,7 +776,6 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
677
776
|
onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
|
|
678
777
|
}, 'Void'),
|
|
679
778
|
h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
|
|
680
|
-
sendToXero(invoice, ui, context),
|
|
681
779
|
] : [];
|
|
682
780
|
return [
|
|
683
781
|
navBar(ui, invoice.id),
|
|
@@ -687,7 +785,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
687
785
|
h('h1', { class: 'fx-title' }, invoice.number),
|
|
688
786
|
h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status])),
|
|
689
787
|
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · issued ${showDate(invoice.issueDate)}`)),
|
|
690
|
-
h('div', { class: 'fx-ph__actions' }, actions)),
|
|
788
|
+
h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
|
|
691
789
|
h('div', { class: 'fx-invoice__grid' },
|
|
692
790
|
h('div', { class: 'fx-invoice__main' },
|
|
693
791
|
h('section', { class: 'fx-card fx-doc' },
|
|
@@ -763,6 +861,7 @@ function sendToXero(invoice, ui, context) {
|
|
|
763
861
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
764
862
|
onclick: async () => {
|
|
765
863
|
button.disabled = true;
|
|
864
|
+
let sent = false;
|
|
766
865
|
try {
|
|
767
866
|
const res = await flow('/api/xero/invoices/push', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) });
|
|
768
867
|
const result = res.invoices?.[0];
|
|
@@ -772,18 +871,81 @@ function sendToXero(invoice, ui, context) {
|
|
|
772
871
|
else if (['created', 'reconciled', 'existing'].includes(result?.outcome)) toast(`${invoice.number} is in Xero as a draft.`, 'success');
|
|
773
872
|
else if (result?.outcome === 'already_in_xero') toast(`Xero already has ${invoice.number}.`, 'info');
|
|
774
873
|
else toast(result?.error?.message ?? 'Xero did not take it.', 'danger');
|
|
874
|
+
sent = ['created', 'reconciled', 'existing', 'already_in_xero'].includes(result?.outcome);
|
|
775
875
|
} catch (error) {
|
|
776
876
|
toast(error.message, 'danger');
|
|
777
877
|
} finally {
|
|
778
|
-
|
|
878
|
+
if (sent) showSent(button);
|
|
879
|
+
else button.disabled = false;
|
|
779
880
|
}
|
|
780
881
|
},
|
|
781
882
|
}, 'Send to Xero');
|
|
883
|
+
// Sent or on its way (Send, or Send all ready): it cannot be sent twice.
|
|
884
|
+
if (['queued', 'sent'].includes(invoice.sendState)) {
|
|
885
|
+
showSent(button);
|
|
886
|
+
return button;
|
|
887
|
+
}
|
|
782
888
|
// The preview reads only Flow's copy of Xero: it costs no Xero calls.
|
|
783
889
|
flow('/api/xero/invoices/preview', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds: [invoice.id] }) })
|
|
784
890
|
.then((res) => {
|
|
785
|
-
if (res.invoices?.[0]?.sendMode === 'approve_and_email') button.textContent = 'Send to Xero and email';
|
|
891
|
+
if (res.invoices?.[0]?.sendMode === 'approve_and_email' && !button.disabled) button.textContent = 'Send to Xero and email';
|
|
786
892
|
})
|
|
787
893
|
.catch(() => {});
|
|
788
894
|
return button;
|
|
789
895
|
}
|
|
896
|
+
|
|
897
|
+
// A sent invoice's Send button stays, greyed, and says it went.
|
|
898
|
+
function showSent(button) {
|
|
899
|
+
if (!button) return;
|
|
900
|
+
button.disabled = true;
|
|
901
|
+
button.textContent = 'Sent to Xero';
|
|
902
|
+
button.title = 'Flow sent this invoice to Xero.';
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// The jobs a new invoice can start from: no invoice yet, newest first.
|
|
906
|
+
const PICKABLE = new Set(['in_work', 'ready', 'pending']);
|
|
907
|
+
|
|
908
|
+
async function pickJob(ui) {
|
|
909
|
+
const { h, flow, toast, go } = ui;
|
|
910
|
+
let jobs;
|
|
911
|
+
try {
|
|
912
|
+
jobs = (await flow('/api/invoicing/jobs?limit=500')).jobs.filter((job) => PICKABLE.has(job.view) && !job.invoiceId);
|
|
913
|
+
} catch (error) {
|
|
914
|
+
toast(error.message, 'danger');
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const search = h('input', { class: 'fx-input', type: 'search', placeholder: 'Find a client or practitioner', 'aria-label': 'Find a job' });
|
|
918
|
+
const rows = jobs.map((job) => {
|
|
919
|
+
const label = h('label', { class: 'fx-pick__row' },
|
|
920
|
+
h('input', { type: 'radio', name: 'job', value: job.id, required: true }),
|
|
921
|
+
h('span', { class: 'fx-pick__main' },
|
|
922
|
+
h('span', { class: 'fx-strong' }, job.contactName ?? 'No client yet'),
|
|
923
|
+
h('span', { class: 'fx-dim' }, [job.practitioner, job.shippedOn ? showDate(job.shippedOn) : null].filter(Boolean).join(' · '))),
|
|
924
|
+
h('span', { class: 'fx-num fx-strong' }, job.totalCents == null ? '—' : formatCents(job.totalCents)));
|
|
925
|
+
label.dataset.find = `${job.contactName ?? ''} ${job.practitioner ?? ''}`.toLowerCase();
|
|
926
|
+
return label;
|
|
927
|
+
});
|
|
928
|
+
const list = h('div', { class: 'fx-pick', role: 'radiogroup', 'aria-label': 'Jobs' },
|
|
929
|
+
rows.length ? rows : h('p', { class: 'fx-dim' }, 'Every job has an invoice already.'));
|
|
930
|
+
search.addEventListener('input', () => {
|
|
931
|
+
const q = search.value.trim().toLowerCase();
|
|
932
|
+
for (const row of rows) row.hidden = Boolean(q) && !row.dataset.find.includes(q);
|
|
933
|
+
});
|
|
934
|
+
const picked = await ui.dialog({
|
|
935
|
+
title: 'New invoice from a job',
|
|
936
|
+
body: h('div', { class: 'fx-pick__body' },
|
|
937
|
+
h('p', { class: 'fx-dim' }, 'Flow fills in the client, Reference and lines from the invoice rules, and links the invoice to the job.'),
|
|
938
|
+
search, list),
|
|
939
|
+
confirm: 'Make draft',
|
|
940
|
+
});
|
|
941
|
+
const jobId = picked?.get('job');
|
|
942
|
+
if (!jobId) return;
|
|
943
|
+
try {
|
|
944
|
+
const { invoice } = await flow(`/api/invoicing/jobs/${jobId}/draft`, { method: 'POST', body: '{}' });
|
|
945
|
+
toast('Draft invoice made from the job.', 'success');
|
|
946
|
+
go(`/invoices/${invoice.id}`, { replace: true });
|
|
947
|
+
} catch (error) {
|
|
948
|
+
const reasons = error.details?.reasons;
|
|
949
|
+
toast(reasons?.length ? `Not drafted: ${reasons.join('; ')}.` : error.message, 'danger');
|
|
950
|
+
}
|
|
951
|
+
}
|
package/server/ui/job-board.js
CHANGED
|
@@ -91,7 +91,8 @@ function reasonFix(reason, acct) {
|
|
|
91
91
|
if (reason === 'the work system report is out of date') {
|
|
92
92
|
return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.' };
|
|
93
93
|
}
|
|
94
|
-
if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Compare the invoice with the job, then correct the invoice.');
|
|
94
|
+
if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Compare the invoice with the job, then correct the invoice. If only the job changed, use its new lines.');
|
|
95
|
+
if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.');
|
|
95
96
|
if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.');
|
|
96
97
|
if (/^the accounting evidence is ambiguous/.test(reason)) return fix(`${acct} match unclear`, `Find the invoice for this job in ${acct}, then link it.`);
|
|
97
98
|
if (/^the accounting evidence is conflict/.test(reason)) return fix(`Two ${acct} invoices`, `Find the right invoice for this job in ${acct}. Void the other.`);
|
package/server/ui/jobs.js
CHANGED
|
@@ -18,6 +18,7 @@ const ACTIVITY = {
|
|
|
18
18
|
in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused',
|
|
19
19
|
accounting_checked: 'Checked in the accounting system', payment_recorded: 'Payment recorded', paid: 'Paid in full',
|
|
20
20
|
held: 'Put on hold', review: 'Sent to review', released: 'Hold removed',
|
|
21
|
+
status_moved_back: 'The job status moved back in the lab system', source_lines_used: 'New job lines used on the draft',
|
|
21
22
|
writeback_sent: 'Status sent to the lab system', writeback_failed: 'Status not sent to the lab system',
|
|
22
23
|
writeback_not_supported: 'The lab system does not take the status',
|
|
23
24
|
};
|
|
@@ -58,7 +59,7 @@ export async function renderJobs(main, ui) {
|
|
|
58
59
|
main.classList.add('fx-main--wide');
|
|
59
60
|
main.append(
|
|
60
61
|
pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
|
|
61
|
-
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, search, toggle),
|
|
62
|
+
h('div', { class: 'fx-toolbar fx-toolbar--plain fx-toolbar--end' }, search, toggle),
|
|
62
63
|
host);
|
|
63
64
|
|
|
64
65
|
const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
|
|
@@ -72,12 +73,14 @@ export async function renderJobs(main, ui) {
|
|
|
72
73
|
|
|
73
74
|
function draw() {
|
|
74
75
|
if (!state.jobs.length) {
|
|
76
|
+
main.classList.remove('fx-main--board');
|
|
75
77
|
host.replaceChildren(h('div', { class: 'fx-panel fx-empty' },
|
|
76
78
|
h('p', {}, 'No jobs yet. Jobs appear here once the lab system is connected under Integrations.'),
|
|
77
79
|
h('a', { class: 'fx-btn', href: '/invoices/new', onclick: ui.go }, 'Make an invoice by hand')));
|
|
78
80
|
return;
|
|
79
81
|
}
|
|
80
82
|
const shown = state.jobs.filter((job) => matches(job, state.q));
|
|
83
|
+
main.classList.toggle('fx-main--board', state.layout === 'board');
|
|
81
84
|
host.replaceChildren(state.layout === 'list' ? list(shown) : board(shown));
|
|
82
85
|
}
|
|
83
86
|
|
|
@@ -172,7 +175,24 @@ export async function renderJobs(main, ui) {
|
|
|
172
175
|
if (!o) return [];
|
|
173
176
|
if (o.invoiceId) {
|
|
174
177
|
const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
|
|
175
|
-
|
|
178
|
+
const open = h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label);
|
|
179
|
+
// Only the job changed and the draft is not sent (ledger.js): one tap
|
|
180
|
+
// puts the job's new lines on Flow's own draft.
|
|
181
|
+
if (!o.canUseNewLines) return [open];
|
|
182
|
+
return [h('button', {
|
|
183
|
+
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
184
|
+
onclick: async (event) => {
|
|
185
|
+
event.currentTarget.disabled = true;
|
|
186
|
+
try {
|
|
187
|
+
await flow(`/api/invoicing/jobs/${o.id}/use-new-lines`, { method: 'POST', body: '{}' });
|
|
188
|
+
ui.toast('The draft has the new job lines.', 'success');
|
|
189
|
+
} catch (error) {
|
|
190
|
+
ui.toast(error.message, 'danger');
|
|
191
|
+
}
|
|
192
|
+
close();
|
|
193
|
+
await load();
|
|
194
|
+
},
|
|
195
|
+
}, 'Use new lines'), open];
|
|
176
196
|
}
|
|
177
197
|
// Flow's own stage: a person marks a job ready to invoice, or an
|
|
178
198
|
// automation does (Settings, Automations).
|
package/server/ui/monitoring.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// Errors
|
|
2
|
-
// Sentry project (docs/SENTRY.md). Only while the Sentry
|
|
1
|
+
// Errors, problem reports and performance traces from the browser, to the
|
|
2
|
+
// installation's own Sentry project (docs/SENTRY.md). Only while the Sentry
|
|
3
|
+
// integration is on:
|
|
3
4
|
// GET /api/monitoring says so and gives the project's public DSN. The SDK
|
|
4
5
|
// comes from Sentry's CDN, pinned and checked against its integrity hash.
|
|
5
6
|
// With the integration off, nothing loads and there is no "Report a problem".
|
|
@@ -26,6 +27,36 @@ export function tagNotify(event) {
|
|
|
26
27
|
event.tags = { ...event.tags, notify: email ? 'yes' : 'no' };
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
// A path as a pattern: each id in it becomes ":id", so every invoice's page
|
|
31
|
+
// is one name in a trace. The server names its requests the same way when no
|
|
32
|
+
// route matched (server/core/tracing.js).
|
|
33
|
+
const ID_SEGMENT = /^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-z_-]*\d[0-9a-z_-]{15,})$/i;
|
|
34
|
+
export function routePattern(path) {
|
|
35
|
+
const bare = String(path ?? '').replace(/[?#].*$/, '');
|
|
36
|
+
return bare.split('/').map((part) => (ID_SEGMENT.test(part) ? ':id' : part)).join('/');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// What a trace span keeps: timings, the route, the status. Never a query
|
|
40
|
+
// string (a search can hold a name), a header, a cookie, an address or an
|
|
41
|
+
// email. The person's Flow id stays, as on errors. Used here and by the
|
|
42
|
+
// server (server/integrations/sentry/index.js).
|
|
43
|
+
const DROPPED_ATTRIBUTE = /^(http\.(request|response)\.header\.|client\.|network\.(peer|local)\.|url\.(query|fragment)$|user\.(?!id$))/;
|
|
44
|
+
export function scrubSpan(span) {
|
|
45
|
+
if (!span) return span;
|
|
46
|
+
// "GET /api/search?q=…" loses its query; a database span's SQL keeps its "?".
|
|
47
|
+
const request = /^((?:GET|POST|PUT|PATCH|DELETE) \S*?)[?#]\S*/;
|
|
48
|
+
if (typeof span.name === 'string') span.name = span.name.replace(request, '$1');
|
|
49
|
+
if (typeof span.description === 'string') span.description = span.description.replace(request, '$1');
|
|
50
|
+
const attributes = span.attributes ?? span.data;
|
|
51
|
+
if (attributes) {
|
|
52
|
+
for (const key of Object.keys(attributes)) {
|
|
53
|
+
if (DROPPED_ATTRIBUTE.test(key)) delete attributes[key];
|
|
54
|
+
else if (typeof attributes[key] === 'string' && /(^|\.)url(\.full)?$|^http\.url$/.test(key)) attributes[key] = attributes[key].replace(/[?#].*$/, '');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return span;
|
|
58
|
+
}
|
|
59
|
+
|
|
29
60
|
/** Resolves to a function that opens the "Report a problem" form, or null. */
|
|
30
61
|
export async function startMonitoring({ flow, person }) {
|
|
31
62
|
const config = await flow('/api/monitoring');
|
|
@@ -51,12 +82,23 @@ export async function startMonitoring({ flow, person }) {
|
|
|
51
82
|
submitButtonLabel: 'Send report',
|
|
52
83
|
successMessageText: 'Thank you. Your report is sent.',
|
|
53
84
|
})] : [];
|
|
85
|
+
// Traces: page loads, moves between pages and each click's wait, with the
|
|
86
|
+
// calls to Flow's server joined to the server's own trace. Only when the
|
|
87
|
+
// installation set a share above 0 (SENTRY_TRACES_SAMPLE_RATE).
|
|
88
|
+
const tracesSampleRate = Number(config.tracesSampleRate) || 0;
|
|
89
|
+
if (tracesSampleRate > 0) {
|
|
90
|
+
integrations.push(Sentry.browserTracingIntegration({
|
|
91
|
+
beforeStartSpan: (options) => ({ ...options, name: routePattern(options.name) }),
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
54
94
|
Sentry.init({
|
|
55
95
|
dsn: config.dsn,
|
|
56
96
|
environment: config.environment,
|
|
57
97
|
release: config.release ?? undefined,
|
|
58
98
|
sendDefaultPii: false,
|
|
59
99
|
integrations,
|
|
100
|
+
tracesSampleRate,
|
|
101
|
+
beforeSendSpan: scrubSpan,
|
|
60
102
|
});
|
|
61
103
|
Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
|
|
62
104
|
// Who reported or met the error, so the lab can follow up.
|
package/server/ui/routes.js
CHANGED
|
@@ -20,7 +20,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
20
20
|
const logoDir = join(here, 'logos');
|
|
21
21
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
22
22
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
23
|
-
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
|
|
23
|
+
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
|
|
24
24
|
const MAX_AGE = '1h';
|
|
25
25
|
|
|
26
26
|
// Send one named file from dir. Passing dir as root limits the dotfile check
|