@arcforgelabs/flow 2026.10.26 → 2026.10.29

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 (63) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -2
  4. package/server/api/invoicing.js +313 -46
  5. package/server/app.js +30 -2
  6. package/server/core/automations.js +154 -0
  7. package/server/core/invoice-rules.js +226 -0
  8. package/server/core/invoices.js +8 -3
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/job-board.js +130 -0
  11. package/server/core/ledger.js +57 -17
  12. package/server/core/line-mapping.js +337 -0
  13. package/server/core/monitoring.js +34 -0
  14. package/server/core/pipeline.js +16 -3
  15. package/server/core/sending.js +283 -0
  16. package/server/db/migrate-all.js +26 -0
  17. package/server/db/migrations/0010_invoicing_rules.sql +72 -0
  18. package/server/db/migrations/0011_invoice_rules.sql +27 -0
  19. package/server/db/migrations/0012_invoice_sending.sql +18 -0
  20. package/server/db/migrations/0013_line_mappings.sql +35 -0
  21. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  22. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  23. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  24. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  25. package/server/db/migrations/meta/_journal.json +28 -0
  26. package/server/db/schema.js +158 -0
  27. package/server/import/at-start.js +64 -2
  28. package/server/index.js +17 -5
  29. package/server/integrations/adapters.js +2 -1
  30. package/server/integrations/port.js +6 -0
  31. package/server/integrations/seazona/feed.js +62 -4
  32. package/server/integrations/seazona/index.js +7 -1
  33. package/server/integrations/seazona/lanes.js +4 -1
  34. package/server/integrations/seazona/ledger.js +2 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  37. package/server/integrations/sentry/index.js +105 -0
  38. package/server/integrations/xero/client.js +5 -0
  39. package/server/integrations/xero/drafts.js +127 -17
  40. package/server/integrations/xero/index.js +11 -1
  41. package/server/integrations/xero/lookup.js +6 -2
  42. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  43. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  44. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  45. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  46. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  47. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
  48. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  49. package/server/ui/app-page.js +7 -4
  50. package/server/ui/app.js +122 -6
  51. package/server/ui/automations.js +96 -0
  52. package/server/ui/clerk-appearance.js +21 -0
  53. package/server/ui/flow.css +243 -30
  54. package/server/ui/invoice-rules.js +237 -0
  55. package/server/ui/invoicing-settings.js +110 -0
  56. package/server/ui/invoicing.js +166 -20
  57. package/server/ui/job-board.js +155 -0
  58. package/server/ui/jobs.js +232 -84
  59. package/server/ui/line-mappings.js +60 -0
  60. package/server/ui/logos/sentry.svg +1 -0
  61. package/server/ui/monitoring.js +61 -0
  62. package/server/ui/routes.js +2 -2
  63. package/server/ui/search.js +145 -0
package/server/ui/jobs.js CHANGED
@@ -1,24 +1,33 @@
1
- // Jobs (/jobs): every billable job from the lab system, as a board with one
2
- // column per billing stage, or as a list. A job's stage follows from its
3
- // evidence (server/core/ledger.js ledgerView), so cards are not dragged: a
4
- // job moves when its evidence changes, or when someone drafts its invoice
5
- // here. Data comes from /api/invoicing/jobs (server/api/invoicing.js).
1
+ // Jobs (/jobs): the work system's jobs, as a board with one column per work
2
+ // system status (its own statuses, one to one), or as a list. Cards are not
3
+ // dragged: a job moves when the work system changes its status. A click on a
4
+ // job opens its detail panel: the job, its lines, its invoice, and what to
5
+ // fix. Data comes from /api/invoicing/board (server/core/job-board.js); the
6
+ // rules are in server/ui/job-board.js.
6
7
 
7
8
  import { formatCents } from '/assets/ui/invoicing.js';
9
+ import {
10
+ FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
11
+ } from '/assets/ui/job-board.js';
8
12
 
9
- // Board columns, left to right. Held jobs sit with the jobs that need review.
10
- const STAGES = [
11
- { id: 'review', label: 'Needs review', views: ['review_required', 'held'], tone: 'warn' },
12
- { id: 'pending', label: 'Checking', views: ['pending'], tone: 'mute' },
13
- { id: 'ready', label: 'Ready to bill', views: ['ready'], tone: 'info' },
14
- { id: 'drafted', label: 'Drafted', views: ['drafted'], tone: 'info' },
15
- { id: 'invoiced', label: 'Invoiced', views: ['invoiced'], tone: 'success' },
16
- { id: 'paid', label: 'Paid', views: ['paid'], tone: 'success' },
17
- ];
18
- const STAGE_OF = Object.fromEntries(STAGES.flatMap((stage) => stage.views.map((view) => [view, stage])));
19
13
  const LAYOUT_KEY = 'flow_jobs_layout';
14
+ const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
15
+ // A job's events (server/core/ledger.js), in plain words.
16
+ const ACTIVITY = {
17
+ observed: 'Flow read the job', source_changed: 'The job changed in the lab system', ready: 'Marked ready to invoice',
18
+ in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused',
19
+ accounting_checked: 'Checked in the accounting system', payment_recorded: 'Payment recorded', paid: 'Paid in full',
20
+ held: 'Put on hold', review: 'Sent to review', released: 'Hold removed',
21
+ writeback_sent: 'Status sent to the lab system', writeback_failed: 'Status not sent to the lab system',
22
+ writeback_not_supported: 'The lab system does not take the status',
23
+ };
24
+ const BY = { person: 'A person', automation: 'An automation' };
20
25
 
21
26
  const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
27
+ const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
28
+ const clientOf = (job) => job.clientName ?? 'No client';
29
+ // The one date a card shows: when the job is due, else when it was ordered.
30
+ const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
22
31
 
23
32
  function readLayout() {
24
33
  try { return localStorage.getItem(LAYOUT_KEY) === 'list' ? 'list' : 'board'; } catch { return 'board'; }
@@ -28,15 +37,27 @@ function saveLayout(layout) {
28
37
  try { localStorage.setItem(LAYOUT_KEY, layout); } catch { /* the choice is only a convenience */ }
29
38
  }
30
39
 
40
+ // The local calendar day, as yyyy-mm-dd, to mark late jobs.
41
+ const today = () => {
42
+ const now = new Date();
43
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
44
+ };
45
+
46
+ const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6v3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r=".9" fill="currentColor"/></svg>';
47
+ const PERSON_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="5.5" r="3" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 14c.6-2.8 2.8-4.3 5.5-4.3s4.9 1.5 5.5 4.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
48
+ const CALENDAR_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
49
+
31
50
  export async function renderJobs(main, ui) {
32
51
  const { h, flow, pageHeader, notice } = ui;
33
- const state = { layout: readLayout(), q: '', jobs: [], canDraft: false };
52
+ // Search (search.js) opens a job with no invoice as /jobs?q=<case>.
53
+ const asked = new URLSearchParams(location.search).get('q') ?? '';
54
+ const state = { layout: readLayout(), q: asked.trim().toLowerCase(), jobs: [], statuses: [], accountingName: null, canDraft: false };
34
55
  const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
35
- const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a case, client or practitioner', 'aria-label': 'Find a job' });
56
+ const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a case, client or practitioner', 'aria-label': 'Find a job', value: asked });
36
57
  const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
37
58
  main.classList.add('fx-main--wide');
38
59
  main.append(
39
- pageHeader('Jobs', { subtitle: 'Every billable job from the lab system, by billing stage.' }),
60
+ pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
40
61
  h('div', { class: 'fx-toolbar fx-toolbar--plain' }, search, toggle),
41
62
  host);
42
63
 
@@ -45,12 +66,9 @@ export async function renderJobs(main, ui) {
45
66
  onclick: () => { state.layout = id; saveLayout(id); drawToggle(); draw(); },
46
67
  }, label)));
47
68
  drawToggle();
48
- search.addEventListener('input', () => { state.q = search.value.trim().toLowerCase(); draw(); });
69
+ search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
49
70
 
50
- const visible = () => (state.q
51
- ? state.jobs.filter((job) => [job.caseRef, job.sourceJobRef, job.contactName, job.practitioner, job.invoiceNumber]
52
- .some((value) => value && String(value).toLowerCase().includes(state.q)))
53
- : state.jobs);
71
+ const problemsOf = (job) => fixes(job.obligation, { accountingName: state.accountingName });
54
72
 
55
73
  function draw() {
56
74
  if (!state.jobs.length) {
@@ -59,13 +77,16 @@ export async function renderJobs(main, ui) {
59
77
  h('a', { class: 'fx-btn', href: '/invoices/new', onclick: ui.go }, 'Make an invoice by hand')));
60
78
  return;
61
79
  }
62
- host.replaceChildren(state.layout === 'list' ? list(visible()) : board(visible()));
80
+ const shown = state.jobs.filter((job) => matches(job, state.q));
81
+ host.replaceChildren(state.layout === 'list' ? list(shown) : board(shown));
63
82
  }
64
83
 
65
84
  async function load() {
66
85
  try {
67
- const [{ jobs }, context] = await Promise.all([flow('/api/invoicing/jobs'), flow('/api/invoicing/context')]);
68
- state.jobs = jobs;
86
+ const [body, context] = await Promise.all([flow('/api/invoicing/board'), flow('/api/invoicing/context')]);
87
+ state.jobs = body.jobs;
88
+ state.statuses = body.statuses;
89
+ state.accountingName = body.accountingName;
69
90
  state.canDraft = context.canIssue;
70
91
  } catch (error) {
71
92
  host.replaceChildren(notice(error.message, 'danger'));
@@ -74,80 +95,207 @@ export async function renderJobs(main, ui) {
74
95
  draw();
75
96
  }
76
97
 
77
- function action(job) {
78
- if (job.invoiceId) {
79
- const href = `/invoices/${job.invoiceId}`;
80
- return h('a', { class: 'fx-btn fx-btn--ghost fx-btn--sm', href, onclick: ui.go }, job.invoiceNumber ?? 'Open draft');
81
- }
82
- if (job.view !== 'ready' || !state.canDraft) return null;
98
+ // The small warning mark of a job with a problem. It opens the job's page
99
+ // at its Fix box.
100
+ function problemMark(job) {
101
+ const found = problemsOf(job);
102
+ if (!found.length) return null;
103
+ const label = `Problem: ${problemSummary(found)}. Open to fix.`;
83
104
  return h('button', {
84
- class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button',
105
+ class: 'fx-job__problem', type: 'button', 'aria-label': label, title: label,
106
+ onclick: (event) => { event.stopPropagation(); openJob(job, { atFix: true }); },
107
+ }, svg(WARN_ICON));
108
+ }
109
+
110
+ // One card ("Task card"). The card design may change, so its markup stays
111
+ // here and its CSS in one block of flow.css ("Job card"): client,
112
+ // practitioner, one date, total, and a warning mark when the job has a
113
+ // problem. Never the case number. A late job's date shows in red. No
114
+ // "move to the next stage" tick yet: Flow only reads the lab system.
115
+ function jobCard(job) {
116
+ const date = dateOf(job);
117
+ const day = job.dueOn ?? job.orderedOn ?? job.jobDate;
118
+ const late = job.dueOn && job.dueOn < today() && statusRank(job.status) < FINAL_RANK;
119
+ return h('article', {
120
+ class: 'fx-job', tabindex: '0', role: 'button', 'aria-label': `${clientOf(job)}, open the job`,
121
+ onclick: () => openJob(job),
122
+ onkeydown: (event) => { if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openJob(job); } },
123
+ },
124
+ h('div', { class: 'fx-job__client' }, clientOf(job)),
125
+ job.practitioner ? h('div', { class: 'fx-job__who' }, svg(PERSON_ICON), h('span', {}, job.practitioner)) : null,
126
+ h('div', { class: 'fx-job__foot' },
127
+ day ? h('span', { class: `fx-job__date${late ? ' fx-job__date--late' : ''}`, title: late ? `${date}, past due` : date }, svg(CALENDAR_ICON), showDate(day)) : null,
128
+ h('span', { class: 'fx-job__space' }),
129
+ problemMark(job),
130
+ h('span', { class: 'fx-job__amount' }, job.totalCents == null ? '—' : formatCents(job.totalCents))));
131
+ }
132
+
133
+ // A column's label: the status, in its stage colour, and how many jobs.
134
+ const stagePill = (label, count = null) => h('span', { class: 'fx-stage', 'data-rank': String(statusRank(label)) },
135
+ h('span', { class: 'fx-stage__label' }, label),
136
+ count == null ? null : h('span', { class: 'fx-stage__count' }, String(count)));
137
+
138
+ function board(jobs) {
139
+ const columns = orderStatuses(state.statuses, state.jobs);
140
+ return h('div', { class: 'fx-board' }, columns.map((label) => {
141
+ const inColumn = jobs.filter((job) => (job.status ?? NO_STATUS) === label);
142
+ const total = inColumn.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
143
+ return h('section', { class: 'fx-board__col', 'aria-label': label },
144
+ h('header', { class: 'fx-board__head' },
145
+ stagePill(label, inColumn.length),
146
+ h('span', { class: 'fx-board__sum' }, inColumn.length ? formatCents(total) : '')),
147
+ h('div', { class: 'fx-board__cards' }, inColumn.length ? inColumn.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')));
148
+ }));
149
+ }
150
+
151
+ function list(jobs) {
152
+ if (!jobs.length) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches that search.'));
153
+ return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
154
+ h('thead', {}, h('tr', {},
155
+ h('th', {}, 'Client'), h('th', {}, 'Practitioner'), h('th', {}, 'Stage'), h('th', {}, 'Date'),
156
+ h('th', { class: 'fx-num' }, 'Total'))),
157
+ h('tbody', {}, jobs.map((job) => h('tr', {
158
+ class: 'fx-row-link', tabindex: '0',
159
+ onclick: () => openJob(job),
160
+ onkeydown: (event) => { if (event.key === 'Enter') openJob(job); },
161
+ },
162
+ h('td', {}, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
163
+ h('td', { class: 'fx-dim' }, job.practitioner ?? '—'),
164
+ h('td', {}, job.status ?? NO_STATUS),
165
+ h('td', { class: 'fx-dim' }, dateOf(job) ?? '—'),
166
+ h('td', { class: 'fx-num fx-strong' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))))));
167
+ }
168
+
169
+ // What a person can do with the job's invoice from here.
170
+ function actions(job, close) {
171
+ const o = job.obligation;
172
+ if (!o) return [];
173
+ if (o.invoiceId) {
174
+ 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)];
176
+ }
177
+ // Flow's own stage: a person marks a job ready to invoice, or an
178
+ // automation does (Settings, Automations).
179
+ if (o.view === 'in_work') {
180
+ return [h('button', {
181
+ class: 'fx-btn', type: 'button',
182
+ onclick: async (event) => {
183
+ event.currentTarget.disabled = true;
184
+ try {
185
+ await flow(`/api/invoicing/jobs/${o.id}/ready`, { method: 'POST', body: JSON.stringify({ ready: true }) });
186
+ ui.toast('Marked ready to invoice.', 'success');
187
+ } catch (error) {
188
+ ui.toast(error.message, 'danger');
189
+ }
190
+ close();
191
+ await load();
192
+ },
193
+ }, 'Mark ready to invoice')];
194
+ }
195
+ if (o.view !== 'ready' || !state.canDraft) return [];
196
+ return [h('button', {
197
+ class: 'fx-btn fx-btn--primary', type: 'button',
85
198
  onclick: async (event) => {
86
- event.stopPropagation();
87
199
  const button = event.currentTarget;
88
200
  button.disabled = true;
89
201
  try {
90
- const { invoice } = await flow(`/api/invoicing/jobs/${job.id}/draft`, { method: 'POST', body: '{}' });
202
+ const { invoice } = await flow(`/api/invoicing/jobs/${o.id}/draft`, { method: 'POST', body: '{}' });
91
203
  ui.toast('Draft invoice made.', 'success');
204
+ close();
92
205
  ui.go(`/invoices/${invoice.id}`);
93
206
  } catch (error) {
94
207
  const reasons = error.details?.reasons;
95
208
  ui.toast(reasons?.length ? `Not drafted: ${reasons.join('; ')}.` : error.message, 'danger');
96
- button.disabled = false;
209
+ close();
97
210
  await load();
98
211
  }
99
212
  },
100
- }, 'Draft invoice');
213
+ }, 'Draft invoice')];
101
214
  }
102
215
 
103
- function card(job) {
104
- const stage = STAGE_OF[job.view];
105
- const reasons = ['review', 'pending'].includes(stage?.id) ? job.reasons?.join('; ') : '';
106
- const why = reasons ? reasons[0].toUpperCase() + reasons.slice(1) + '.' : null;
107
- return h('article', { class: `fx-job${stage?.id === 'review' ? ' fx-job--flag' : ''}` },
108
- h('div', { class: 'fx-job__top' },
109
- h('span', { class: 'fx-strong' }, job.caseRef ?? job.sourceJobRef),
110
- h('span', { class: 'fx-job__amount' }, formatCents(job.totalCents))),
111
- h('div', { class: 'fx-job__client' }, job.contactName ?? 'No client'),
112
- h('div', { class: 'fx-job__meta' },
113
- job.practitioner ? h('span', {}, job.practitioner) : null,
114
- h('span', {}, `Shipped ${showDate(job.shippedOn)}`)),
115
- why ? h('p', { class: 'fx-job__why' }, why) : null,
116
- action(job));
117
- }
118
-
119
- function board(jobs) {
120
- return h('div', { class: 'fx-board' }, STAGES.map((stage) => {
121
- const inStage = jobs.filter((job) => stage.views.includes(job.view));
122
- const total = inStage.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
123
- return h('section', { class: 'fx-board__col', 'aria-label': stage.label },
124
- h('header', { class: 'fx-board__head' },
125
- h('span', { class: 'fx-board__dot', 'data-tone': stage.tone }),
126
- h('span', { class: 'fx-strong' }, stage.label),
127
- h('span', { class: 'fx-board__count' }, String(inStage.length)),
128
- h('span', { class: 'fx-board__sum' }, inStage.length ? formatCents(total) : '')),
129
- h('div', { class: 'fx-board__cards' }, inStage.length ? inStage.map(card) : h('p', { class: 'fx-board__none' }, 'None')));
130
- }));
131
- }
132
-
133
- function list(jobs) {
134
- if (!jobs.length) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches that search.'));
135
- return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table' },
136
- h('thead', {}, h('tr', {},
137
- h('th', {}, 'Case'), h('th', {}, 'Client'), h('th', {}, 'Practitioner'), h('th', {}, 'Shipped'),
138
- h('th', {}, 'Stage'), h('th', { class: 'fx-num' }, 'Total'), h('th', { class: 'fx-table__actions' }, h('span', { class: 'fx-sr' }, 'Action')))),
139
- h('tbody', {}, jobs.map((job) => {
140
- const stage = STAGE_OF[job.view];
141
- return h('tr', {},
142
- h('td', { class: 'fx-strong' }, job.caseRef ?? job.sourceJobRef),
143
- h('td', {}, job.contactName ?? '—'),
144
- h('td', { class: 'fx-dim' }, job.practitioner ?? '—'),
145
- h('td', { class: 'fx-dim' }, showDate(job.shippedOn)),
146
- h('td', {}, h('span', { class: 'fx-status', 'data-tone': stage?.tone ?? 'mute', title: job.reasons?.join('; ') ?? '' }, stage?.label ?? job.view)),
147
- h('td', { class: 'fx-num fx-strong' }, formatCents(job.totalCents)),
148
- h('td', { class: 'fx-table__actions' }, action(job)));
149
- }))));
216
+ // The job's page: a large panel over the board, like a task page. A
217
+ // breadcrumb, the client's name, the stage, the job's fields, what to fix
218
+ // and its lines; its activity at the side (below on a phone). Esc, the X
219
+ // or a click outside closes it. `atFix` scrolls to what to fix. Nothing is
220
+ // edited here yet: Flow only reads the lab system.
221
+ function openJob(job, { atFix = false } = {}) {
222
+ const o = job.obligation;
223
+ const stage = invoiceStage(o);
224
+ const found = problemsOf(job);
225
+ const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
226
+ const status = job.status ?? NO_STATUS;
227
+ const late = job.dueOn && job.dueOn < today() && statusRank(job.status) < FINAL_RANK;
228
+ const field = (term, value, attrs = {}) => (value == null || value === '' ? null
229
+ : h('div', {}, h('dt', {}, term), h('dd', attrs, value)));
230
+ const link = (attrs, text) => h('a', { class: 'fx-task__link', ...attrs }, text);
231
+ const element = h('dialog', { class: 'fx-dialog fx-task', 'aria-label': clientOf(job) });
232
+ const close = () => element.close();
233
+ const invoiceText = o?.invoiceId
234
+ ? link({ href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } },
235
+ `${o.invoiceStatus === 'draft' ? 'Draft' : 'Invoice'} ${o.invoiceNumber ?? ''}`.trim() + (stage ? ` · ${INVOICE_STAGES[stage]}` : ''))
236
+ : stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
237
+ const fixBox = found.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
238
+ h('h3', {}, 'Fix'),
239
+ found.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, `${f.text} ${f.todo}`)))) : null;
240
+ const lines = job.lines ?? [];
241
+ const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
242
+ element.append(h('div', { class: 'fx-task__grid' },
243
+ h('div', { class: 'fx-task__main' },
244
+ h('header', { class: 'fx-task__head' },
245
+ h('nav', { class: 'fx-task__crumb', 'aria-label': 'Breadcrumb' },
246
+ h('span', {}, 'Jobs'), h('span', { class: 'fx-task__slash', 'aria-hidden': 'true' }, '/'), h('span', {}, status)),
247
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', onclick: close }, '✕')),
248
+ h('h2', { class: 'fx-task__title' }, clientOf(job)),
249
+ h('div', { class: 'fx-task__stage' }, stagePill(status)),
250
+ h('dl', { class: 'fx-task__fields' },
251
+ field('Practitioner', job.practitioner),
252
+ field(job.dueOn ? 'Due' : 'Ordered', job.dueOn ? fullDate(job.dueOn) : job.orderedOn ? fullDate(job.orderedOn) : null, late ? { class: 'fx-task__late' } : {}),
253
+ job.dueOn ? field('Ordered', job.orderedOn ? fullDate(job.orderedOn) : null) : null,
254
+ field('Total', job.totalCents == null ? null : formatCents(job.totalCents)),
255
+ field('Case number', job.caseRef ?? job.sourceJobRef, { class: 'fx-mono' }),
256
+ field(`${system} status`, job.status),
257
+ field(`${system} client`, job.workClientName && job.workClientName !== job.clientName ? job.workClientName : null),
258
+ field('Department', job.department),
259
+ field('Invoice', invoiceText),
260
+ o?.accountingUrl ? field(state.accountingName ?? 'Accounting', link({ href: o.accountingUrl, target: '_blank', rel: 'noopener' }, `Open in ${state.accountingName ?? 'the accounting system'}`)) : null),
261
+ fixBox,
262
+ h('section', { class: 'fx-task__lines' },
263
+ h('h3', {}, 'Lines'),
264
+ lines.length ? h('table', {},
265
+ h('colgroup', {}, h('col', {}), h('col', { class: 'fx-task__col-code' }), h('col', { class: 'fx-task__col-amount' })),
266
+ h('thead', {}, h('tr', {}, h('th', {}, 'Item'), h('th', {}, 'Price code'), h('th', { class: 'fx-num' }, 'Amount'))),
267
+ h('tbody', {}, lines.map((line) => h('tr', {},
268
+ h('td', {}, line.description),
269
+ line.code && line.mapped ? h('td', { class: 'fx-mono' }, line.code)
270
+ : h('td', { class: 'fx-task__miss', title: line.code ? `${line.code} is not in the price list` : null }, line.code ? `${line.code}: not in the price list` : 'No price code'),
271
+ h('td', { class: 'fx-num' }, line.amountCents == null ? '—' : formatCents(line.amountCents))))),
272
+ h('tfoot', {}, h('tr', {}, h('td', {}, 'Total'), h('td', {}),
273
+ h('td', { class: 'fx-num' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))))
274
+ : h('p', { class: 'fx-dim' }, 'No lines.')),
275
+ h('div', { class: 'fx-task__actions' }, actions(job, close))),
276
+ h('aside', { class: 'fx-task__activity', 'aria-label': 'Activity' },
277
+ h('h3', {}, 'Activity'),
278
+ o ? activity : h('p', { class: 'fx-dim' }, `Flow has no invoice record for this job yet. It makes one when ${system} reports the job complete.`))));
279
+ element.addEventListener('click', (event) => { if (event.target === element) close(); });
280
+ element.addEventListener('close', () => element.remove());
281
+ document.body.append(element);
282
+ element.showModal();
283
+ if (atFix && fixBox) fixBox.scrollIntoView({ block: 'start' });
284
+ if (!o) return;
285
+ const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
286
+ flow(`/api/invoicing/jobs/${o.id}/activity`).then(({ events }) => {
287
+ activity.replaceChildren(...(events.length ? events.map((e) => h('li', {},
288
+ `${ACTIVITY[e.kind] ?? e.kind.replaceAll('_', ' ')} (${BY[e.by] ?? SYSTEM_NAME[e.by] ?? e.by})`,
289
+ h('time', { datetime: e.createdAt }, when(e.createdAt))))
290
+ : [h('li', {}, 'Nothing yet.')]));
291
+ }).catch(() => activity.replaceChildren(h('li', {}, 'Flow could not read the activity.')));
150
292
  }
151
293
 
152
294
  await load();
153
295
  }
296
+
297
+ function svg(markup) {
298
+ const span = document.createElement('span');
299
+ span.innerHTML = markup;
300
+ return span.firstChild;
301
+ }
@@ -0,0 +1,60 @@
1
+ // Settings, Invoicing, "Price codes" (server/core/line-mapping.js,
2
+ // /api/invoicing/line-labels and /line-mappings). Each work system line label
3
+ // that found no price item is one row: its text, its price, the jobs it holds
4
+ // back, and a menu of the lab's price items. A choice saves at once as a saved
5
+ // mapping for that label at that price, and the next pipeline run maps every
6
+ // job that has it. Everyone can read them; an owner or admin chooses. The
7
+ // caller passes in app.js's shared pieces as `ui`.
8
+
9
+ const money = (cents) => (cents === null || cents === undefined ? '' : `$${(cents / 100).toFixed(2)}`);
10
+
11
+ export function lineMappingSection(ui) {
12
+ const { h, flow, toast, notice, me } = ui;
13
+ const disabled = !['owner', 'admin'].includes(me?.person.role);
14
+ const body = h('div', {}, h('div', { class: 'fx-skeleton' }));
15
+ const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Price codes'), body);
16
+
17
+ const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
18
+ h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
19
+ h('div', { class: 'fx-set__control' }, control));
20
+
21
+ async function load() {
22
+ let labels;
23
+ let items;
24
+ try {
25
+ [{ labels }, { priceItems: items }] = await Promise.all([flow('/api/invoicing/line-labels'), flow('/api/invoicing/price-items')]);
26
+ } catch (error) {
27
+ body.replaceChildren(notice(error.message, 'danger'));
28
+ return;
29
+ }
30
+ const waiting = labels.filter((l) => l.unmapped > 0);
31
+ 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
+ return;
34
+ }
35
+ const options = [h('option', { value: '' }, 'Choose a price item…'),
36
+ ...items.map((i) => h('option', { value: i.code }, `${i.code} · ${i.name} · ${money(i.unitAmountCents)}`))];
37
+ body.replaceChildren(...waiting.map((label) => {
38
+ const select = h('select', { class: 'fx-select', 'aria-label': `Price item for ${label.sample}`, disabled }, options.map((o) => o.cloneNode(true)));
39
+ select.addEventListener('change', async () => {
40
+ if (!select.value) return;
41
+ select.disabled = true;
42
+ try {
43
+ await flow('/api/invoicing/line-mappings', {
44
+ method: 'POST',
45
+ body: JSON.stringify({ kind: 'saved', label: label.label, unitAmountCents: label.unitAmountCents ?? 0, itemCode: select.value }),
46
+ });
47
+ toast('Saved. Jobs with this line update in a few minutes.', 'success');
48
+ await load();
49
+ } catch (error) {
50
+ toast(error.message, 'danger');
51
+ select.disabled = disabled;
52
+ }
53
+ });
54
+ const lines = label.unmapped === 1 ? '1 line waits' : `${label.unmapped} lines wait`;
55
+ return row(label.sample, `${money(label.unitAmountCents)} · ${lines}`, select);
56
+ }));
57
+ }
58
+ load();
59
+ return section;
60
+ }
@@ -0,0 +1 @@
1
+ <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="#362D59"><title>Sentry</title><path d="M13.91 2.505c-.873-1.448-2.972-1.448-3.844 0L6.904 7.92a15.478 15.478 0 0 1 8.53 12.811h-2.221A13.301 13.301 0 0 0 5.784 9.814l-2.926 5.06a7.65 7.65 0 0 1 4.435 5.848H2.194a.365.365 0 0 1-.298-.534l1.413-2.402a5.16 5.16 0 0 0-1.614-.913L.296 19.275a2.182 2.182 0 0 0 .812 2.999 2.24 2.24 0 0 0 1.086.288h6.983a9.322 9.322 0 0 0-3.845-8.318l1.11-1.922a11.47 11.47 0 0 1 4.95 10.24h5.915a17.242 17.242 0 0 0-7.885-15.28l2.244-3.845a.37.37 0 0 1 .504-.13c.255.14 9.75 16.708 9.928 16.9a.365.365 0 0 1-.327.543h-2.287c.029.612.029 1.223 0 1.831h2.297a2.206 2.206 0 0 0 1.922-3.31z"/></svg>
@@ -0,0 +1,61 @@
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:
3
+ // GET /api/monitoring says so and gives the project's public DSN. The SDK
4
+ // comes from Sentry's CDN, pinned and checked against its integrity hash.
5
+ // With the integration off, nothing loads and there is no "Report a problem".
6
+
7
+ function loadScript({ src, integrity }) {
8
+ return new Promise((resolve, reject) => {
9
+ const script = document.createElement('script');
10
+ script.src = src;
11
+ script.integrity = integrity;
12
+ script.crossOrigin = 'anonymous';
13
+ script.onload = resolve;
14
+ script.onerror = () => reject(new Error('The Sentry SDK could not load.'));
15
+ document.head.append(script);
16
+ });
17
+ }
18
+
19
+ /** Resolves to a function that opens the "Report a problem" form, or null. */
20
+ export async function startMonitoring({ flow, person }) {
21
+ const config = await flow('/api/monitoring');
22
+ if (!config.on || config.service !== 'sentry') return null;
23
+ await loadScript(config.script);
24
+ const Sentry = window.Sentry;
25
+ const integrations = config.feedback ? [Sentry.feedbackIntegration({
26
+ autoInject: false,
27
+ // The form's colours and font come from flow.css (#sentry-feedback), so
28
+ // it follows each person's theme and mode, even when they change it.
29
+ colorScheme: 'light',
30
+ showBranding: false,
31
+ // Flow already knows who is signed in (setUser below).
32
+ showName: false,
33
+ enableScreenshot: true,
34
+ triggerLabel: 'Report a problem',
35
+ formTitle: 'Report a problem',
36
+ messageLabel: 'What went wrong?',
37
+ messagePlaceholder: 'What you did, what you expected, and what happened.',
38
+ submitButtonLabel: 'Send report',
39
+ successMessageText: 'Thank you. Your report is sent.',
40
+ })] : [];
41
+ Sentry.init({
42
+ dsn: config.dsn,
43
+ environment: config.environment,
44
+ release: config.release ?? undefined,
45
+ sendDefaultPii: false,
46
+ integrations,
47
+ });
48
+ // Who reported or met the error, so the lab can follow up.
49
+ Sentry.setUser({ id: person.id, email: person.email });
50
+ const feedback = config.feedback ? Sentry.getFeedback() : null;
51
+ if (!feedback) return null;
52
+ // One form, made on first use and opened again after that.
53
+ let form = null;
54
+ return async () => {
55
+ if (!form) {
56
+ form = await feedback.createForm();
57
+ form.appendToDom();
58
+ }
59
+ form.open();
60
+ };
61
+ }
@@ -18,9 +18,9 @@ const require = createRequire(import.meta.url);
18
18
  const here = dirname(fileURLToPath(import.meta.url));
19
19
  const themeDir = join(here, '..', 'theme');
20
20
  const logoDir = join(here, 'logos');
21
- const LOGO_FILES = new Set(['xero.svg', 'seazona.svg']);
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', 'jobs.js', 'flow-mark.svg']);
23
+ const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.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