@arcforgelabs/flow 2026.10.36 → 2026.10.38

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 (38) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/api/custom-fields.js +2 -1
  5. package/server/api/invoicing.js +61 -3
  6. package/server/auth/clerk.js +35 -0
  7. package/server/auth/routes.js +45 -6
  8. package/server/core/custom-fields.js +18 -0
  9. package/server/core/host.js +24 -0
  10. package/server/core/job-board.js +2 -1
  11. package/server/core/job-match.js +241 -0
  12. package/server/core/ledger.js +80 -1
  13. package/server/index.js +9 -3
  14. package/server/integrations/seazona/feed.js +4 -1
  15. package/server/integrations/seazona/index.js +1 -1
  16. package/server/integrations/seazona/settings.js +5 -2
  17. package/server/integrations/sentry/index.js +5 -2
  18. package/server/integrations/xero/fit.js +108 -0
  19. package/server/integrations/xero/import.js +6 -1
  20. package/server/integrations/xero/index.js +4 -4
  21. package/server/integrations/xero/ledger.js +3 -0
  22. package/server/integrations/xero/links.js +293 -0
  23. package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
  24. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  25. package/server/integrations/xero/reconcile.js +17 -11
  26. package/server/integrations/xero/routes.js +42 -0
  27. package/server/integrations/xero/settings.js +12 -5
  28. package/server/ui/app-page.js +1 -1
  29. package/server/ui/app.js +142 -23
  30. package/server/ui/bulk-bar.js +100 -0
  31. package/server/ui/flow.css +225 -11
  32. package/server/ui/invoicing.js +234 -30
  33. package/server/ui/job-board.js +60 -34
  34. package/server/ui/job-view.js +30 -7
  35. package/server/ui/jobs.js +469 -38
  36. package/server/ui/reconcile.js +275 -0
  37. package/server/ui/routes.js +1 -1
  38. package/server/ui/xero-import.js +329 -0
package/server/ui/jobs.js CHANGED
@@ -12,20 +12,23 @@
12
12
  // shows what to do there and the card waits in its new lane. On a phone,
13
13
  // or with a keyboard, the job's panel has the same moves.
14
14
 
15
+ import { bulkBar } from '/assets/ui/bulk-bar.js';
16
+ import { lucide } from '/assets/ui/custom-fields.js';
15
17
  import { formatCents } from '/assets/ui/invoicing.js';
16
18
  import {
17
- FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
19
+ caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
18
20
  } from '/assets/ui/job-board.js';
19
21
  import {
20
- DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, filterCount, filterJobs, groupJobs, GROUPS, laneOf, normaliseJobsView, NOT_SET, sortJobs, SORTS,
22
+ COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, filterCount, filterJobs, groupJobs, GROUPS, laneOf, normaliseJobsView, NOT_SET, sortJobs, SORTS,
21
23
  } from '/assets/ui/job-view.js';
22
24
 
23
25
  const LAYOUT_KEY = 'flow_jobs_layout';
26
+ const FOLDED_KEY = 'flow_jobs_folded';
24
27
  const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
25
28
  // A job's events (server/core/ledger.js), in plain words.
26
29
  const ACTIVITY = {
27
30
  observed: 'Flow read the job', source_changed: 'The job changed in the lab system', ready: 'Marked ready to invoice',
28
- in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused',
31
+ in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused', invoice_linked: 'Linked to an invoice',
29
32
  accounting_checked: 'Checked in the accounting system', payment_recorded: 'Payment recorded', paid: 'Paid in full',
30
33
  held: 'Put on hold', review: 'Sent to review', released: 'Hold removed',
31
34
  status_moved_back: 'The job status moved back in the lab system', source_lines_used: 'New job lines used on the draft',
@@ -39,6 +42,23 @@ const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4
39
42
 
40
43
  const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
41
44
  const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
45
+ // A list date, short: Today, Tomorrow or Yesterday, else day/month (and
46
+ // year when it is not this year).
47
+ const shortDate = (iso) => {
48
+ if (!iso) return '—';
49
+ const at = new Date(`${iso}T00:00:00`);
50
+ const now = new Date();
51
+ const days = Math.round((at - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
52
+ if (days === 0) return 'Today';
53
+ if (days === 1) return 'Tomorrow';
54
+ if (days === -1) return 'Yesterday';
55
+ return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
56
+ };
57
+ // A person's badge: their initials in a circle; the name on hover.
58
+ const initialsOf = (name) => {
59
+ const parts = (name ?? '?').split(/[\s._-]+/).filter(Boolean);
60
+ return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
61
+ };
42
62
  const clientOf = (job) => job.clientName ?? 'No client';
43
63
  // The one date a card shows: when the job is due, else when it was ordered.
44
64
  const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
@@ -51,6 +71,18 @@ function saveLayout(layout) {
51
71
  try { localStorage.setItem(LAYOUT_KEY, layout); } catch { /* the choice is only a convenience */ }
52
72
  }
53
73
 
74
+ // The list's folded groups, by group key, in this browser only.
75
+ function readFolded() {
76
+ try {
77
+ const value = JSON.parse(localStorage.getItem(FOLDED_KEY) ?? '{}');
78
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
79
+ } catch { return {}; }
80
+ }
81
+
82
+ function saveFolded(folded) {
83
+ try { localStorage.setItem(FOLDED_KEY, JSON.stringify(folded)); } catch { /* a convenience only */ }
84
+ }
85
+
54
86
  // The local calendar day, as yyyy-mm-dd, to mark late jobs.
55
87
  const today = () => {
56
88
  const now = new Date();
@@ -61,8 +93,10 @@ const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="
61
93
  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>';
62
94
  const GROUP_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.8 14.5 5 8 8.2 1.5 5z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="m1.5 8.2 6.5 3.2 6.5-3.2M1.5 11.2 8 14.4l6.5-3.2" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
63
95
  const SORT_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5-3.5L11.5 6M4.5 10 8 13.5l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
96
+ const COLUMNS_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M6 2.5v11M10 2.5v11" stroke="currentColor" stroke-width="1.3"/></svg>';
64
97
  const FILTER_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.5h12M4.5 8h7M7 12.5h2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>';
65
98
  const TICK_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 8.5 3.2 3L13 4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
99
+ const CHEVRON_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3.5 4.5 4.5L6 12.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
66
100
  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>';
67
101
 
68
102
  export async function renderJobs(main, ui) {
@@ -74,23 +108,28 @@ export async function renderJobs(main, ui) {
74
108
  const saved = ui.me?.views?.jobs;
75
109
  const state = {
76
110
  view: normaliseJobsView(saved ?? { ...DEFAULT_VIEW, layout: readLayout() }),
77
- q: asked.trim().toLowerCase(), jobs: [], statuses: [], accountingName: null, canDraft: false,
111
+ q: asked.trim().toLowerCase(), jobs: [], statuses: [], canDraft: false,
78
112
  // Custom fields (docs/CUSTOM_FIELDS.md): the lab's fields, the work
79
113
  // systems' stage fields, the people a person field can name, and each
80
114
  // job's values by job and field.
81
115
  fields: [], systemFields: [], people: [], values: new Map(),
82
116
  // Each work system's "Ask before stage moves", read on the first move.
83
117
  askBefore: new Map(),
118
+ // The list's ticked jobs (by jobKey), and its folded groups by group key.
119
+ ticked: new Set(), folded: readFolded(),
84
120
  };
85
121
  const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
86
122
  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 });
87
123
  const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
88
124
  const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
125
+ // In the list, ticked rows get the same floating bar as Invoices.
126
+ const bulk = bulkBar(ui, { label: 'Ticked jobs' });
89
127
  main.classList.add('fx-main--wide');
90
128
  main.append(
91
129
  pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
92
130
  h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, search, toggle)),
93
- host);
131
+ host,
132
+ bulk.element);
94
133
 
95
134
  const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
96
135
  type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
@@ -99,10 +138,24 @@ export async function renderJobs(main, ui) {
99
138
  drawToggle();
100
139
  search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
101
140
 
102
- const problemsOf = (job) => fixes(job.obligation, { accountingName: state.accountingName });
141
+ const problemsOf = (job) => fixes(job.obligation);
142
+ const jobKey = (job) => `${job.sourceSystem}\u0000${job.sourceJobRef}`;
103
143
  const valueKey = (job, fieldId) => `${job.sourceSystem}\u0000${job.sourceJobRef}\u0000${fieldId}`;
104
144
  const valueOf = (job, fieldId) => state.values.get(valueKey(job, fieldId)) ?? null;
105
145
  const groupable = () => state.fields.filter((f) => f.groupable);
146
+ // The Assignee: the lab's first person custom field (Flow makes one,
147
+ // server/core/custom-fields.js ensureAssignee). "Assignee: me" keeps the
148
+ // jobs it names the person looking at.
149
+ const assigneeField = () => state.fields.find((f) => f.type === 'person') ?? null;
150
+ const isMine = (job) => {
151
+ const field = assigneeField();
152
+ return Boolean(field && ui.me?.person?.id && valueOf(job, field.id)?.value === ui.me.person.id);
153
+ };
154
+ // The list's columns a person can choose: Patient only when the
155
+ // installation keeps patient names; Assignee only with a person field.
156
+ const builtInColumns = () => Object.keys(COLUMNS).filter((id) => (id !== 'patient' || state.jobs.some((job) => job.patient))
157
+ && (id !== 'assignee' || assigneeField()));
158
+ const customColumns = () => state.fields.filter((f) => f.id !== assigneeField()?.id);
106
159
  const fieldOfGroup = (group) => groupable().find((f) => f.id === fieldIdOf(group)) ?? null;
107
160
  // A work system whose stages Flow can hand a move to (Seazona).
108
161
  const movesStages = (job) => state.systemFields.some((f) => f.source === job.sourceSystem);
@@ -157,7 +210,8 @@ export async function renderJobs(main, ui) {
157
210
  type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
158
211
  }, h('span', { class: 'fx-pop__tick' }, checked ? svg(TICK_ICON) : null), h('span', {}, text));
159
212
  const heading = (text) => h('p', { class: 'fx-pop__head' }, text);
160
- const DIR_WORDS = { total: ['Low to high', 'High to low'], due: ['Oldest first', 'Newest first'], board: ['As the lab system has it', 'Reversed'] };
213
+ const DATE_WORDS = ['Oldest first', 'Newest first'];
214
+ const DIR_WORDS = { total: ['Low to high', 'High to low'], due: DATE_WORDS, ordered: DATE_WORDS, shipped: DATE_WORDS, board: ['As the lab system has it', 'Reversed'] };
161
215
 
162
216
  const groupMenu = menu(GROUP_ICON, (_redraw, close) => [
163
217
  heading('Group by'),
@@ -182,6 +236,7 @@ export async function renderJobs(main, ui) {
182
236
  heading('Show only'),
183
237
  option('Late jobs', state.view.late, () => { change({ late: !state.view.late }); redraw(); }, 'menuitemcheckbox'),
184
238
  option('Jobs with a problem', state.view.problems, () => { change({ problems: !state.view.problems }); redraw(); }, 'menuitemcheckbox'),
239
+ assigneeField() ? option(`${assigneeField().name}: me`, state.view.mine, () => { change({ mine: !state.view.mine }); redraw(); }, 'menuitemcheckbox') : null,
185
240
  h('div', { class: 'fx-pop__sep' }),
186
241
  heading('Statuses'),
187
242
  ...orderStatuses(state.statuses, state.jobs).map((label) => option(label, !hidden.has(label), () => {
@@ -191,20 +246,43 @@ export async function renderJobs(main, ui) {
191
246
  redraw();
192
247
  }, 'menuitemcheckbox')),
193
248
  filterCount(state.view) ? h('div', { class: 'fx-pop__foot' }, h('button', {
194
- type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick: () => { change({ hide: [], late: false, problems: false }); redraw(); },
249
+ type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick: () => { change({ hide: [], late: false, problems: false, mine: false }); redraw(); },
195
250
  }, 'Clear filters')) : null,
196
251
  ];
197
252
  });
198
- controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node);
253
+ // Columns (the list only): Client always shows; each other column can be
254
+ // shown or hidden. Custom fields are columns too.
255
+ const columnsMenu = menu(COLUMNS_ICON, (redraw) => {
256
+ const on = new Set(state.view.columns);
257
+ const flip = (id) => {
258
+ const next = on.has(id) ? state.view.columns.filter((c) => c !== id) : [...state.view.columns, id];
259
+ change({ columns: next });
260
+ redraw();
261
+ };
262
+ const custom = customColumns();
263
+ return [
264
+ heading('Columns'),
265
+ option('Client', true, () => {}, 'menuitemcheckbox'),
266
+ ...builtInColumns().map((id) => option(COLUMNS[id], on.has(id), () => flip(id), 'menuitemcheckbox')),
267
+ custom.length ? h('div', { class: 'fx-pop__sep' }) : null,
268
+ custom.length ? heading('Custom fields') : null,
269
+ ...custom.map((f) => option(f.name, on.has(fieldGroup(f.id)), () => flip(fieldGroup(f.id)), 'menuitemcheckbox')),
270
+ ];
271
+ });
272
+ controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node, ...columnsMenu.node);
199
273
 
200
274
  function drawControls() {
201
275
  const { group, sort, dir } = state.view;
202
276
  const field = fieldOfGroup(group);
203
- groupMenu.set(`Group: ${field?.name ?? GROUPS[group] ?? GROUPS.status}`, group !== DEFAULT_VIEW.group && (Boolean(field) || Boolean(GROUPS[group])));
277
+ // The board has no "None": it shows the status lanes.
278
+ const shown = group === 'none' && state.view.layout === 'board' ? 'status' : group;
279
+ groupMenu.set(`Group: ${field?.name ?? GROUPS[shown] ?? GROUPS.status}`, shown !== DEFAULT_VIEW.group && (Boolean(field) || Boolean(GROUPS[shown])));
204
280
  const sorted = sort !== DEFAULT_VIEW.sort || dir !== DEFAULT_VIEW.dir;
205
281
  sortMenu.set(sorted ? `Sort: ${SORTS[sort]}${dir === 'desc' ? ' ↓' : ''}` : 'Sort', sorted);
206
282
  const count = filterCount(state.view);
207
283
  filterMenu.set(count ? `Filter · ${count}` : 'Filter', count > 0);
284
+ columnsMenu.set('Columns', false);
285
+ columnsMenu.node[0].hidden = state.view.layout !== 'list';
208
286
  }
209
287
  drawControls();
210
288
 
@@ -217,10 +295,15 @@ export async function renderJobs(main, ui) {
217
295
  return;
218
296
  }
219
297
  const found = filterJobs(state.jobs.filter((job) => matches(job, state.q)), state.view,
220
- { status: statusOf, late: isLate, problem: (job) => problemsOf(job).length > 0 });
298
+ { status: statusOf, late: isLate, problem: (job) => problemsOf(job).length > 0, mine: isMine });
221
299
  const groups = grouped(sortJobs(found, state.view));
222
300
  main.classList.toggle('fx-main--board', state.view.layout === 'board');
301
+ // A tick on a job the search or filter now hides goes.
302
+ const shown = new Set(found.map(jobKey));
303
+ for (const key of state.ticked) if (!shown.has(key)) state.ticked.delete(key);
304
+ state.found = found;
223
305
  host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
306
+ drawBulk();
224
307
  }
225
308
 
226
309
  async function load() {
@@ -232,7 +315,6 @@ export async function renderJobs(main, ui) {
232
315
  ]);
233
316
  state.jobs = body.jobs;
234
317
  state.statuses = body.statuses;
235
- state.accountingName = body.accountingName;
236
318
  state.canDraft = context.canIssue;
237
319
  state.fields = fields?.fields ?? [];
238
320
  state.systemFields = fields?.systemFields ?? [];
@@ -309,7 +391,8 @@ export async function renderJobs(main, ui) {
309
391
  return { ...g, rank: 'plain', color: lane?.color, drop: lane && !lane.archived ? { kind: 'field', field, value: lane.value } : null };
310
392
  });
311
393
  }
312
- if (group === 'status' || !GROUPS[group]) {
394
+ if (group === 'none' && state.view.layout === 'list') return [{ label: null, jobs, rank: 'plain' }];
395
+ if (group === 'status' || group === 'none' || !GROUPS[group]) {
313
396
  const hidden = new Set(state.view.hide);
314
397
  return groupJobs(jobs, orderStatuses(state.statuses, state.jobs).filter((label) => !hidden.has(label)), laneStatusOf)
315
398
  .map((g) => ({ ...g, rank: statusRank(g.label), drop: g.label === NO_STATUS ? null : { kind: 'stage', status: g.label } }));
@@ -495,7 +578,7 @@ export async function renderJobs(main, ui) {
495
578
  try {
496
579
  const { move } = await flow('/api/work/moves', { method: 'POST', body: JSON.stringify({ ...ref, toStatus: status }) });
497
580
  job.pendingMove = { to: move.to, from: move.from, requestedAt: move.requestedAt, expiresAt: move.expiresAt, openUrl: move.openUrl, openExact: move.openExact };
498
- ui.toast?.(`Waiting for ${system}. Set case ${move.caseRef} to ${move.to} there.`, 'success');
581
+ ui.toast?.(`Waiting for ${system}. Set ${caseNumber(move) ? `case ${caseNumber(move)}` : 'the case'} to ${move.to} there.`, 'success');
499
582
  } catch (error) {
500
583
  job.pendingMove = before;
501
584
  ui.toast?.(error.message, 'danger');
@@ -512,13 +595,14 @@ export async function renderJobs(main, ui) {
512
595
  // system's web app. An exact link opens the order itself; otherwise it
513
596
  // opens the lab's home page there, and the person finds the case.
514
597
  function openStep(job, system) {
515
- const caseRef = job.caseRef ?? job.sourceJobRef;
598
+ const caseRef = caseNumber(job);
599
+ const named = caseRef ? `case ${caseRef}` : `the case for ${clientOf(job)}`;
516
600
  const url = job.link?.url ?? null;
517
- if (!url) return `Open case ${caseRef} in ${system}.`;
601
+ if (!url) return `Open ${named} in ${system}.`;
518
602
  const out = (text) => h('a', { class: 'fx-task__link', href: url, target: '_blank', rel: 'noopener' }, text);
519
603
  return job.link.exact
520
- ? h('span', {}, out(`Open case ${caseRef} in ${system} ↗`))
521
- : h('span', {}, out(`Open ${system} ↗`), ` and find case ${caseRef}.`);
604
+ ? h('span', {}, out(caseRef ? `Open case ${caseRef} in ${system} ↗` : `Open the case in ${system} ↗`))
605
+ : h('span', {}, out(`Open ${system} ↗`), ` and find ${named}.`);
522
606
  }
523
607
 
524
608
  // The job panel's custom fields: one control each, saved when it changes.
@@ -569,27 +653,331 @@ export async function renderJobs(main, ui) {
569
653
  return h('div', { class: 'fx-task__move' }, wait, select);
570
654
  }
571
655
 
656
+ // A column's menu (a click on its head, or a right-click): sort by it,
657
+ // move it, hide it. Client always shows. `column` null is the + menu: the
658
+ // hidden columns, to add one back.
659
+ const SORT_OF = { practitioner: 'practitioner', due: 'due', ordered: 'ordered', shipped: 'shipped', total: 'total' };
660
+ const columnPop = h('div', { class: 'fx-pop', id: 'fx-jobs-colmenu', popover: 'auto', role: 'menu' });
661
+ document.body.append(columnPop);
662
+ // A popover opened while the right button is still down closes again on
663
+ // its release (light dismiss), so open it after the release.
664
+ function onRightButton(event, open) {
665
+ if (event.buttons & 2) addEventListener('pointerup', () => setTimeout(open), { once: true });
666
+ else open();
667
+ }
668
+ function columnMenu(column, event) {
669
+ const ids = state.view.columns;
670
+ const close = () => columnPop.hidePopover?.();
671
+ const item = (icon, text, run) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); run(); } },
672
+ h('span', { class: 'fx-pop__tick' }, lucide(h, icon)), h('span', {}, text));
673
+ const parts = [];
674
+ if (column) {
675
+ const sort = SORT_OF[column.id];
676
+ if (sort) {
677
+ const [up, down] = DIR_WORDS[sort] ?? ['A to Z', 'Z to A'];
678
+ parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sort, dir: 'asc' })),
679
+ item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sort, dir: 'desc' })), h('div', { class: 'fx-pop__sep' }));
680
+ }
681
+ const rest = ids.filter((c) => c !== column.id);
682
+ parts.push(item('arrow-left-to-line', 'Move to start', () => change({ columns: [column.id, ...rest] })),
683
+ item('arrow-right-to-line', 'Move to end', () => change({ columns: [...rest, column.id] })));
684
+ if (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)) {
685
+ parts.push(item('settings', 'Edit custom field', () => ui.go('/custom-fields')));
686
+ }
687
+ parts.push(h('div', { class: 'fx-pop__sep' }), item('eye-off', 'Hide column', () => change({ columns: rest })));
688
+ }
689
+ // + lists the hidden columns: a click adds one at the end.
690
+ if (!column) {
691
+ const shown = new Set(ids);
692
+ const hidden = [
693
+ ...builtInColumns().filter((id) => !shown.has(id)).map((id) => ({ id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id] })),
694
+ ...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
695
+ ];
696
+ parts.push(...hidden.map((c) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ columns: [...ids, c.id] }); } }, c.label)));
697
+ if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
698
+ }
699
+ columnPop.replaceChildren(...parts);
700
+ columnPop.showPopover?.();
701
+ const x = event.clientX || event.currentTarget?.getBoundingClientRect?.().left || 0;
702
+ const y = event.clientY || event.currentTarget?.getBoundingClientRect?.().bottom || 0;
703
+ columnPop.style.top = `${Math.round(Math.min(y + 4, innerHeight - columnPop.offsetHeight - 8))}px`;
704
+ columnPop.style.left = `${Math.round(Math.max(8, Math.min(x, innerWidth - columnPop.offsetWidth - 8)))}px`;
705
+ }
706
+
707
+ // The list's columns, in the order the person chose them. One column holds
708
+ // one field; each date has its own column.
709
+ function listColumns() {
710
+ const day = (iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
711
+ const late = (job) => (isLate(job) ? h('span', { class: 'fx-job__date--late', title: 'Past due' }, day(job.dueOn)) : day(job.dueOn));
712
+ const BUILT_IN = {
713
+ case: { kind: 'case', dim: true, cell: (job) => caseNumber(job) ?? '—' },
714
+ practitioner: { kind: 'text', dim: true, cell: (job) => job.practitioner ?? '—' },
715
+ patient: { kind: 'text', dim: true, cell: (job) => job.patient ?? '—' },
716
+ stage: { kind: 'stage', cell: (job) => stagePill(statusOf(job)) },
717
+ assignee: { kind: 'person', cell: (job) => personBadge(job, assigneeField()) },
718
+ ordered: { kind: 'date', dim: true, cell: (job) => day(job.orderedOn) },
719
+ due: { kind: 'date', dim: true, cell: late },
720
+ shipped: { kind: 'date', dim: true, cell: (job) => day(job.shippedOn) },
721
+ total: { kind: 'total', num: true, strong: true, cell: (job) => (job.totalCents == null ? '—' : formatCents(job.totalCents)) },
722
+ };
723
+ const allowed = new Set(builtInColumns());
724
+ return state.view.columns.map((id) => {
725
+ if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
726
+ const field = customColumns().find((f) => fieldGroup(f.id) === id);
727
+ if (!field) return null;
728
+ const cell = field.type === 'person' ? (job) => personBadge(job, field)
729
+ : field.type === 'date' ? (job) => day(valueOf(job, field.id)?.value ?? null) : (job) => fieldText(job, field);
730
+ return { id, label: field.name, kind: field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text', num: field.type === 'number', dim: true, cell };
731
+ }).filter(Boolean);
732
+ }
733
+
734
+ // A person custom field's value as a badge (initials), the name on hover.
735
+ function personBadge(job, field) {
736
+ const value = field ? valueOf(job, field.id)?.value ?? null : null;
737
+ if (!value) return h('span', { class: 'fx-dim' }, '—');
738
+ const name = state.people.find((p) => p.id === value)?.name ?? 'Former person';
739
+ return h('span', { class: 'fx-avatar fx-avatar--sm', role: 'img', 'aria-label': name, title: name }, initialsOf(name));
740
+ }
741
+
742
+ // A job's custom field value as text.
743
+ function fieldText(job, field) {
744
+ const value = field ? valueOf(job, field.id)?.value ?? null : null;
745
+ if (value == null || value === false) return '—';
746
+ if (field.type === 'checkbox') return 'Yes';
747
+ if (field.type === 'person') return state.people.find((p) => p.id === value)?.name ?? 'Former person';
748
+ if (field.type === 'dropdown') return field.options?.find((o) => o.id === value)?.name ?? '—';
749
+ if (field.type === 'date') return showDate(value);
750
+ return String(value);
751
+ }
752
+
572
753
  // The list: one block per group, with the group's label over its rows.
754
+ // A tick column at the left; a group's head has a tick for every job in
755
+ // the group, and a click on the head folds the group or opens it. Grouped
756
+ // by None, it is one plain list with no heads.
573
757
  function list(groups, count) {
574
758
  if (!count) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
759
+ const shownGroups = groups.filter((g) => g.jobs.length);
760
+ const folded = new Set(state.folded[state.view.group] ?? []);
761
+ const allJobs = shownGroups.flatMap((g) => g.jobs);
762
+ const boxes = [];
763
+ // A tick box for a set of jobs: ticked when all are, half when some are.
764
+ const setBox = (jobs, label) => {
765
+ const box = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': label, onclick: (event) => event.stopPropagation() });
766
+ const sync = () => {
767
+ const on = jobs.filter((job) => state.ticked.has(jobKey(job))).length;
768
+ box.checked = on > 0 && on === jobs.length;
769
+ box.indeterminate = on > 0 && on < jobs.length;
770
+ };
771
+ box.addEventListener('change', () => {
772
+ for (const job of jobs) { if (box.checked) state.ticked.add(jobKey(job)); else state.ticked.delete(jobKey(job)); }
773
+ syncAll();
774
+ });
775
+ boxes.push(sync);
776
+ sync();
777
+ return box;
778
+ };
779
+ const syncAll = () => { for (const sync of boxes) sync(); drawBulk(); };
780
+ const tickCell = (box) => h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, box);
781
+ const columns = listColumns();
575
782
  const row = (job) => h('tr', {
576
783
  class: 'fx-row-link', tabindex: '0',
577
784
  onclick: () => openJob(job),
578
- onkeydown: (event) => { if (event.key === 'Enter') openJob(job); },
785
+ onkeydown: (event) => { if (event.key === 'Enter' && event.target === event.currentTarget) openJob(job); },
579
786
  },
580
- h('td', {}, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
581
- h('td', { class: 'fx-dim' }, job.practitioner ?? '—'),
582
- h('td', {}, job.status ?? NO_STATUS),
583
- h('td', { class: 'fx-dim' }, dateOf(job) ?? '—'),
584
- h('td', { class: 'fx-num fx-strong' }, job.totalCents == null ? '—' : formatCents(job.totalCents)));
787
+ tickCell(setBox([job], `Tick ${clientOf(job)}${caseNumber(job) ? `, case ${caseNumber(job)}` : ''}`)),
788
+ h('td', { class: 'fx-c-client' }, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
789
+ columns.map((c) => h('td', { class: `fx-c-${c.kind}${c.num ? ' fx-num' : ''}${c.dim ? ' fx-dim' : ''}${c.strong ? ' fx-strong' : ''}`, 'data-label': c.label }, c.cell(job))),
790
+ h('td', { class: 'fx-c-end' }));
791
+ const fold = (label) => {
792
+ const next = new Set(state.folded[state.view.group] ?? []);
793
+ if (next.has(label)) next.delete(label); else next.add(label);
794
+ state.folded = { ...state.folded, [state.view.group]: [...next] };
795
+ saveFolded(state.folded);
796
+ draw();
797
+ };
798
+ const head = ({ label, jobs, rank, color }) => {
799
+ const open = !folded.has(label);
800
+ const toggle = h('button', {
801
+ type: 'button', class: 'fx-table__fold', 'aria-expanded': String(open), 'aria-label': `${label}, ${jobs.length} ${jobs.length === 1 ? 'job' : 'jobs'}. ${open ? 'Fold' : 'Open'} the group`,
802
+ onclick: (event) => { event.stopPropagation(); fold(label); },
803
+ }, svg(CHEVRON_ICON),
804
+ color ? h('span', { class: 'fx-cf__opt fx-board__lane', 'data-color': color }, label, h('span', { class: 'fx-stage__count' }, String(jobs.length))) : stagePill(label, jobs.length, rank));
805
+ return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: () => fold(label) },
806
+ tickCell(setBox(jobs, `Tick every job in ${label}`)),
807
+ h('td', { colspan: String(columns.length + 2) }, h('span', { class: 'fx-table__group-row' }, toggle, h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))))));
808
+ };
585
809
  return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
586
810
  h('thead', {}, h('tr', {},
587
- h('th', {}, 'Client'), h('th', {}, 'Practitioner'), h('th', {}, 'Stage'), h('th', {}, 'Date'),
588
- h('th', { class: 'fx-num' }, 'Total'))),
589
- groups.filter((g) => g.jobs.length).map(({ label, jobs, rank }) => h('tbody', {},
590
- h('tr', { class: 'fx-table__group' }, h('td', { colspan: '5' },
591
- h('span', { class: 'fx-table__group-row' }, stagePill(label, jobs.length, rank), h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs)))))),
592
- jobs.map(row)))));
811
+ h('th', { class: 'fx-tick-cell' }, setBox(allJobs, 'Tick every job shown')),
812
+ h('th', {}, 'Client'),
813
+ columns.map((c) => h('th', { class: c.num ? 'fx-num' : null, oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => columnMenu(c, event)); } },
814
+ h('button', { type: 'button', class: 'fx-th', 'aria-haspopup': 'menu', title: 'Column menu (or right-click)', onclick: (event) => columnMenu(c, event) }, c.label))),
815
+ h('th', { class: 'fx-th-add' }, h('button', {
816
+ type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => columnMenu(null, event),
817
+ }, '+')))),
818
+ shownGroups.map((g) => h('tbody', {},
819
+ g.label == null ? null : head(g),
820
+ g.label != null && folded.has(g.label) ? null : g.jobs.map(row)))));
821
+ }
822
+
823
+ // ── Bulk actions ─────────────────────────────────────────────────────────
824
+ // The bar floats at the foot while list rows are ticked (bulk-bar.js, as on
825
+ // Invoices). Move and Ready are the main steps; Copy and Export take the
826
+ // ticked rows out; More has them all, a field per custom field, and the
827
+ // Selection steps.
828
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
829
+ const tickedJobs = () => (state.found ?? []).filter((job) => state.ticked.has(jobKey(job)));
830
+ const setTicks = (jobs) => { state.ticked = new Set(jobs.map(jobKey)); draw(); };
831
+
832
+ function drawBulk() {
833
+ const rows = state.view.layout === 'list' ? tickedJobs() : [];
834
+ bulk.draw({ count: rows.length, onClear: () => setTicks([]), ...bulkActions(rows) });
835
+ }
836
+
837
+ function bulkActions(rows) {
838
+ if (!rows.length) return { groups: [], more: [] };
839
+ const status = [{ icon: 'arrow-right-left', label: 'Move', title: 'Move to a stage', run: () => moveMany(rows) }];
840
+ if (state.fields.length) status.push({ icon: 'tag', label: 'Custom field', title: 'Set a custom field', run: () => setMany(rows) });
841
+ const workable = rows.filter((job) => job.obligation?.view === 'in_work');
842
+ if (workable.length) status.push({ icon: 'check-check', label: 'Ready', title: 'Mark ready to invoice', run: () => readyMany(workable) });
843
+ const draftable = state.canDraft ? rows.filter((job) => job.obligation?.view === 'ready' && !job.obligation.invoiceId) : [];
844
+ if (draftable.length) status.push({ icon: 'file-plus', label: 'Draft', title: 'Draft invoices', run: () => draftMany(draftable) });
845
+ const fields = state.fields.map((field) => ({ icon: 'tag', label: field.name, title: `Set ${field.name}`, run: () => setMany(rows, field.id) }));
846
+ const apply = [
847
+ { icon: 'copy', label: 'Copy', iconOnly: true, title: 'Copy case numbers', run: () => copyLines(rows.map((job) => caseNumber(job) ?? clientOf(job))) },
848
+ { icon: 'download', label: 'Export', iconOnly: true, title: 'Export CSV', run: () => exportCsv(rows) },
849
+ ];
850
+ const all = state.found ?? [];
851
+ const selection = [
852
+ { icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
853
+ { icon: 'square-check', label: 'Invert ticks', run: () => setTicks(all.filter((job) => !state.ticked.has(jobKey(job)))) },
854
+ { icon: 'square-x', label: 'Clear ticks', run: () => setTicks([]) },
855
+ ];
856
+ return {
857
+ groups: [status, apply],
858
+ more: [
859
+ { head: 'Change stage', actions: status },
860
+ { head: 'Set a custom field', actions: fields },
861
+ { head: 'Apply an action', actions: apply },
862
+ { head: 'Selection', actions: selection },
863
+ ],
864
+ };
865
+ }
866
+
867
+ // Move the ticked jobs to one stage. A work system's own stages
868
+ // (Seazona) cannot be moved from Flow in bulk yet, so this says so; a
869
+ // lab's own stages are custom fields (Field). Someone who needs it can
870
+ // report it (Report a problem).
871
+ async function moveMany(jobs) {
872
+ const systems = [...new Set(jobs.map((job) => SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem))].join(' and ');
873
+ await ui.dialog({
874
+ title: 'Not yet available',
875
+ body: h('p', {}, `Moving ${plural(jobs.length, 'job', 'jobs')} to a ${systems} stage at once is not yet available.`),
876
+ confirm: 'OK',
877
+ });
878
+ }
879
+
880
+ // One step on each job's invoice record, one job after the other.
881
+ async function eachJob(jobs, step) {
882
+ let done = 0;
883
+ const refused = [];
884
+ for (const job of jobs) {
885
+ try { await step(job); done += 1; } catch (e) { refused.push(e); }
886
+ }
887
+ return { done, refused };
888
+ }
889
+
890
+ async function readyMany(jobs) {
891
+ const { done, refused } = await eachJob(jobs, (job) => flow(`/api/invoicing/jobs/${job.obligation.id}/ready`, { method: 'POST', body: JSON.stringify({ ready: true }) }));
892
+ ui.toast?.(refused.length ? `${done} ready to invoice. ${refused[0].message}` : `${plural(done, 'job', 'jobs')} ready to invoice.`, refused.length ? 'danger' : 'success');
893
+ state.ticked.clear();
894
+ await load();
895
+ }
896
+
897
+ async function draftMany(jobs) {
898
+ const ok = await ui.dialog({
899
+ title: `Draft ${plural(jobs.length, 'invoice', 'invoices')}?`,
900
+ body: h('p', {}, 'Flow makes a draft invoice for each job. The drafts stay in Flow until a person checks them.'),
901
+ confirm: `Draft ${jobs.length}`,
902
+ });
903
+ if (!ok) return;
904
+ const { done, refused } = await eachJob(jobs, (job) => flow(`/api/invoicing/jobs/${job.obligation.id}/draft`, { method: 'POST', body: '{}' }));
905
+ ui.toast?.(refused.length ? `${done} drafted. ${plural(refused.length, 'job was', 'jobs were')} not drafted: open ${refused.length === 1 ? 'it' : 'them'} to see why.` : `${plural(done, 'draft invoice', 'draft invoices')} made.`, refused.length ? 'danger' : 'success');
906
+ state.ticked.clear();
907
+ await load();
908
+ }
909
+
910
+ // Set one custom field on the ticked jobs, in Flow only: choose the
911
+ // custom field, then its value. `fieldId` starts on that custom field.
912
+ // A dropdown, person or checkbox has a list of values; a date, number or
913
+ // text has a box. An empty box clears the value.
914
+ async function setMany(jobs, fieldId = null) {
915
+ const fields = state.fields;
916
+ if (!fields.length) return;
917
+ const lanesOf = (field) => fieldLanes(field, { people: state.people, jobs: state.jobs, valueOf: (job) => valueOf(job, field.id)?.value ?? null }).filter((lane) => !lane.archived);
918
+ const listed = (field) => field.groupable;
919
+ const pickField = h('select', { class: 'fx-select', name: 'field', 'aria-label': 'Custom field' },
920
+ fields.map((f) => h('option', { value: f.id, selected: f.id === fieldId }, f.name)));
921
+ const valueHost = h('div', {});
922
+ const fieldOf = (id) => fields.find((f) => f.id === id) ?? fields[0];
923
+ const fillValue = () => {
924
+ const field = fieldOf(pickField.value);
925
+ const control = listed(field)
926
+ ? h('select', { class: 'fx-select', name: 'value', 'aria-label': 'Value' }, lanesOf(field).map((lane, i) => h('option', { value: String(i) }, lane.label)))
927
+ : h('input', {
928
+ class: 'fx-input', name: 'value', 'aria-label': 'Value',
929
+ type: field.type === 'date' ? 'date' : field.type === 'number' ? 'number' : 'text', step: field.type === 'number' ? 'any' : null, maxlength: field.type === 'text' ? '500' : null,
930
+ });
931
+ valueHost.replaceChildren(h('label', { class: 'fx-field' }, h('span', {}, 'Value'), control));
932
+ };
933
+ pickField.addEventListener('change', fillValue);
934
+ fillValue();
935
+ const data = await ui.dialog({
936
+ title: `Set a custom field on ${plural(jobs.length, 'job', 'jobs')}`,
937
+ body: h('div', { class: 'fx-fields' }, h('label', { class: 'fx-field' }, h('span', {}, 'Custom field'), pickField), valueHost),
938
+ confirm: 'Set',
939
+ });
940
+ if (!data) return;
941
+ const field = fields.find((f) => f.id === data.get('field'));
942
+ if (!field) return;
943
+ let value;
944
+ let shown;
945
+ if (listed(field)) {
946
+ const lane = lanesOf(field)[Number(data.get('value'))];
947
+ if (!lane) return;
948
+ ({ value } = lane);
949
+ shown = lane.label;
950
+ } else {
951
+ const raw = String(data.get('value') ?? '').trim();
952
+ value = raw === '' ? null : field.type === 'number' ? Number(raw) : raw;
953
+ shown = raw === '' ? 'empty' : field.type === 'date' ? fullDate(raw) : raw;
954
+ }
955
+ for (const job of jobs) await setValue(job, field, value);
956
+ ui.toast?.(`${field.name} set to ${shown} on ${plural(jobs.length, 'job', 'jobs')}.`, 'success');
957
+ }
958
+
959
+ async function copyLines(lines) {
960
+ try {
961
+ await navigator.clipboard.writeText(lines.join('\n'));
962
+ ui.toast?.(`${plural(lines.length, 'line', 'lines')} copied.`, 'success');
963
+ } catch {
964
+ ui.toast?.('The browser did not allow a copy.', 'danger');
965
+ }
966
+ }
967
+
968
+ // The ticked rows as a CSV file, with the columns on the screen.
969
+ function exportCsv(jobs) {
970
+ const cell = (v) => (/[",\n]/.test(String(v ?? '')) ? `"${String(v).replace(/"/g, '""')}"` : String(v ?? ''));
971
+ const lines = [['Case', 'Client', 'Practitioner', 'Stage', 'Due', 'Ordered', 'Total']]
972
+ .concat(jobs.map((job) => [caseNumber(job) ?? '', clientOf(job), job.practitioner ?? '', statusOf(job), job.dueOn ?? '', job.orderedOn ?? '',
973
+ job.totalCents == null ? '' : (job.totalCents / 100).toFixed(2)]));
974
+ const url = URL.createObjectURL(new Blob([lines.map((l) => l.map(cell).join(',')).join('\r\n')], { type: 'text/csv' }));
975
+ const a = h('a', { href: url, download: `jobs-${today()}.csv` });
976
+ document.body.append(a);
977
+ a.click();
978
+ a.remove();
979
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
980
+ ui.toast?.(`${plural(jobs.length, 'job', 'jobs')} exported.`, 'success');
593
981
  }
594
982
 
595
983
  // What a person can do with the job's invoice from here.
@@ -667,6 +1055,7 @@ export async function renderJobs(main, ui) {
667
1055
  const o = job.obligation;
668
1056
  const stage = invoiceStage(o);
669
1057
  const found = problemsOf(job);
1058
+ const choose = found.find((f) => f.go === 'choose') ?? null;
670
1059
  const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
671
1060
  const status = job.status ?? NO_STATUS;
672
1061
  const late = isLate(job);
@@ -678,10 +1067,50 @@ export async function renderJobs(main, ui) {
678
1067
  const invoiceText = o?.invoiceId
679
1068
  ? link({ href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } },
680
1069
  `${o.invoiceStatus === 'draft' ? 'Draft' : 'Invoice'} ${o.invoiceNumber ?? ''}`.trim() + (stage ? ` · ${INVOICE_STAGES[stage]}` : ''))
681
- : stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
682
- const fixBox = found.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
1070
+ : choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
1071
+ : stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
1072
+ const rest = found.filter((f) => f.go !== 'choose');
1073
+ const fixBox = rest.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
683
1074
  h('h3', {}, 'Fix'),
684
- found.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, `${f.text} ${f.todo}`)))) : null;
1075
+ rest.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, [f.text, f.todo].filter(Boolean).join(' ')), fixLink(f.go)))) : null;
1076
+ // No invoice, and the match is unclear: one line and two buttons. The
1077
+ // possible matches are compared on the Reconcile page, not here.
1078
+ const chooseBox = choose ? h('section', { class: 'fx-task__choose', 'aria-label': 'Invoice' },
1079
+ svg(WARN_ICON),
1080
+ h('span', {}, choose.text),
1081
+ h('div', { class: 'fx-task__choices' },
1082
+ state.canDraft ? h('button', { class: 'fx-btn fx-btn--primary', type: 'button', onclick: (event) => makeInvoice(event.currentTarget) }, 'Make invoice') : null,
1083
+ h('a', { class: 'fx-btn', href: `/reconcile/${o.id}`, onclick: (event) => { close(); ui.go(event); } }, 'Reconcile'))) : null;
1084
+ // "Make invoice": the person says the job has no invoice yet, then Flow
1085
+ // drafts it (the live check before the draft still runs).
1086
+ async function makeInvoice(button) {
1087
+ if (button) button.disabled = true;
1088
+ try {
1089
+ await flow(`/api/invoicing/jobs/${o.id}/no-invoice`, { method: 'POST', body: '{}' });
1090
+ const { invoice } = await flow(`/api/invoicing/jobs/${o.id}/draft`, { method: 'POST', body: '{}' });
1091
+ ui.toast('Draft invoice made.', 'success');
1092
+ close();
1093
+ ui.go(`/invoices/${invoice.id}`);
1094
+ } catch (error) {
1095
+ const reasons = error.details?.reasons;
1096
+ ui.toast(reasons?.length ? `Not drafted: ${reasons.join('; ')}.` : error.message, 'danger');
1097
+ close();
1098
+ await load();
1099
+ }
1100
+ }
1101
+ // Each fix's link: Flow's own invoice, the job in the work system, or
1102
+ // the Flow page where the fix is made (job-board.js GO).
1103
+ function fixLink(go) {
1104
+ const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, onclick: (event) => { close(); ui.go(event); } }, text);
1105
+ if (go === 'invoice' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
1106
+ if (go === 'work' && job.link?.url) {
1107
+ return h('a', { class: 'fx-task__link', href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`);
1108
+ }
1109
+ if (go === 'price-list') return inFlow('/price-list', 'Open price list');
1110
+ if (go === 'invoicing-settings') return inFlow('/invoicing-settings', 'Open invoice rules');
1111
+ if (go === 'integrations' && ui.me?.person?.role === 'owner') return inFlow('/integrations', 'Open integrations');
1112
+ return null;
1113
+ }
685
1114
  const lines = job.lines ?? [];
686
1115
  const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
687
1116
  element.append(h('div', { class: 'fx-task__grid' },
@@ -697,16 +1126,18 @@ export async function renderJobs(main, ui) {
697
1126
  field(job.dueOn ? 'Due' : 'Ordered', job.dueOn ? fullDate(job.dueOn) : job.orderedOn ? fullDate(job.orderedOn) : null, late ? { class: 'fx-task__late' } : {}),
698
1127
  job.dueOn ? field('Ordered', job.orderedOn ? fullDate(job.orderedOn) : null) : null,
699
1128
  field('Total', job.totalCents == null ? null : formatCents(job.totalCents)),
700
- field('Case number', job.caseRef ?? job.sourceJobRef, { class: 'fx-mono' }),
1129
+ field('Case number', caseNumber(job), { class: 'fx-mono' }),
701
1130
  job.link?.url ? field(system, link({ href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`)) : null,
702
1131
  field(`${system} status`, job.status),
703
1132
  field(`${system} client`, job.workClientName && job.workClientName !== job.clientName ? job.workClientName : null),
704
1133
  field('Department', job.department),
705
- field('Invoice', invoiceText),
706
- o?.accountingUrl ? field(state.accountingName ?? 'Accounting', link({ href: o.accountingUrl, target: '_blank', rel: 'noopener' }, `Open in ${state.accountingName ?? 'the accounting system'}`)) : null),
1134
+ // The job links to Flow's invoice only. The invoice page shows its
1135
+ // link to an accounting system (its Linked card).
1136
+ field('Invoice', invoiceText)),
1137
+ chooseBox,
707
1138
  fixBox,
708
1139
  state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
709
- h('h3', {}, 'Fields'),
1140
+ h('h3', {}, 'Custom fields'),
710
1141
  h('dl', { class: 'fx-task__fields' }, state.fields.map((f) => h('div', {}, h('dt', {}, f.name), h('dd', {}, fieldControl(job, f)))))) : null,
711
1142
  movesStages(job) ? h('section', { class: 'fx-task__custom', 'aria-label': `${system} stage` },
712
1143
  h('h3', {}, `${system} stage`), stageControl(job)) : null,
@@ -731,7 +1162,7 @@ export async function renderJobs(main, ui) {
731
1162
  element.addEventListener('close', () => element.remove());
732
1163
  document.body.append(element);
733
1164
  element.showModal();
734
- if (atFix && fixBox) fixBox.scrollIntoView({ block: 'start' });
1165
+ if (atFix && (chooseBox ?? fixBox)) (chooseBox ?? fixBox).scrollIntoView({ block: 'start' });
735
1166
  if (!o) return;
736
1167
  const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
737
1168
  flow(`/api/invoicing/jobs/${o.id}/activity`).then(({ events }) => {