@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.
Files changed (57) hide show
  1. package/README.md +9 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/custom-fields.js +114 -0
  5. package/server/api/invoicing.js +58 -25
  6. package/server/api/work-moves.js +80 -0
  7. package/server/app.js +16 -0
  8. package/server/core/custom-fields.js +300 -0
  9. package/server/core/invoicing-rules.js +50 -4
  10. package/server/core/job-board.js +17 -4
  11. package/server/core/ledger.js +242 -6
  12. package/server/core/pipeline.js +29 -21
  13. package/server/core/read-before-act.js +96 -0
  14. package/server/core/reconcile.js +350 -0
  15. package/server/core/sending.js +44 -6
  16. package/server/core/tracing.js +64 -0
  17. package/server/db/migrations/0017_sync_basis.sql +1 -0
  18. package/server/db/migrations/0018_custom_fields.sql +78 -0
  19. package/server/db/migrations/meta/0017_snapshot.json +3867 -0
  20. package/server/db/migrations/meta/0018_snapshot.json +4289 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +62 -0
  23. package/server/index.js +11 -1
  24. package/server/integrations/port.js +10 -1
  25. package/server/integrations/seazona/act.js +48 -0
  26. package/server/integrations/seazona/feed.js +70 -50
  27. package/server/integrations/seazona/governor.js +28 -2
  28. package/server/integrations/seazona/index.js +107 -4
  29. package/server/integrations/seazona/lanes.js +81 -8
  30. package/server/integrations/seazona/ledger.js +14 -9
  31. package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
  32. package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
  33. package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
  34. package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
  35. package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
  36. package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
  37. package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
  38. package/server/integrations/seazona/reconcile.js +40 -0
  39. package/server/integrations/seazona/scheduler.js +32 -4
  40. package/server/integrations/seazona/schema.js +47 -0
  41. package/server/integrations/seazona/settings.js +26 -0
  42. package/server/integrations/seazona/stage-moves.js +135 -0
  43. package/server/integrations/seazona/store.js +24 -2
  44. package/server/integrations/seazona/sweep.js +574 -0
  45. package/server/integrations/seazona/tables.js +3 -0
  46. package/server/integrations/sentry/index.js +61 -10
  47. package/server/integrations/xero/index.js +4 -0
  48. package/server/integrations/xero/webhooks.js +35 -0
  49. package/server/ui/app-page.js +1 -1
  50. package/server/ui/app.js +40 -2
  51. package/server/ui/custom-fields.js +190 -0
  52. package/server/ui/flow.css +57 -9
  53. package/server/ui/invoicing.js +184 -22
  54. package/server/ui/job-board.js +2 -1
  55. package/server/ui/jobs.js +22 -2
  56. package/server/ui/monitoring.js +44 -2
  57. package/server/ui/routes.js +1 -1
@@ -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
- const query = new URLSearchParams({ status: state.status, ...(state.q ? { q: state.q } : {}) });
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
- const shown = new Set(body.invoices.map((i) => i.id));
177
- for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
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' && body.counts?.problem ? { 'data-tone': 'danger' } : {}),
183
- onclick: () => openTab(id),
184
- }, label, h('span', { class: 'fx-tabs__count' }, String(body.counts?.[id] ?? 0)))));
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', {}, h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, invoice.number ?? 'Draft')),
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
- function openNext(ui, id) {
293
- const nav = readNav();
294
- const ids = nav?.ids ?? [];
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 ? (ids[at + 1] ?? ids[at - 1]) : null;
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: 'Case or order number' });
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
- button.disabled = false;
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
+ }
@@ -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
- return [h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label)];
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).
@@ -1,5 +1,6 @@
1
- // Errors and problem reports from the browser, to the installation's own
2
- // Sentry project (docs/SENTRY.md). Only while the Sentry integration is on:
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.
@@ -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