@arcforgelabs/flow 2026.10.37 → 2026.10.39

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 +2 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +27 -9
  5. package/server/api/invoicing.js +198 -51
  6. package/server/app.js +5 -2
  7. package/server/core/alerts.js +1 -1
  8. package/server/core/automations.js +1 -1
  9. package/server/core/custom-fields.js +89 -3
  10. package/server/core/job-board.js +2 -1
  11. package/server/core/job-match.js +241 -0
  12. package/server/core/ledger.js +109 -8
  13. package/server/core/price-list.js +27 -2
  14. package/server/core/read-before-act.js +3 -2
  15. package/server/core/sending.js +36 -18
  16. package/server/db/doctor.js +18 -19
  17. package/server/db/index.js +74 -3
  18. package/server/db/migrate-all.js +3 -4
  19. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  20. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  21. package/server/db/migrations/meta/_journal.json +7 -0
  22. package/server/db/schema.js +14 -0
  23. package/server/integrations/call-guard.js +148 -0
  24. package/server/integrations/port.js +3 -2
  25. package/server/integrations/schema.js +9 -5
  26. package/server/integrations/seazona/feed.js +4 -1
  27. package/server/integrations/seazona/settings.js +8 -4
  28. package/server/integrations/xero/drafts.js +4 -4
  29. package/server/integrations/xero/fit.js +108 -0
  30. package/server/integrations/xero/import.js +6 -1
  31. package/server/integrations/xero/index.js +6 -2
  32. package/server/integrations/xero/ledger.js +3 -0
  33. package/server/integrations/xero/links.js +293 -0
  34. package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
  35. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  36. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  37. package/server/integrations/xero/mirror.js +4 -3
  38. package/server/integrations/xero/reconcile.js +42 -12
  39. package/server/integrations/xero/routes.js +66 -7
  40. package/server/integrations/xero/settings.js +6 -5
  41. package/server/ui/app-page.js +1 -1
  42. package/server/ui/app.js +89 -3
  43. package/server/ui/bulk-bar.js +4 -3
  44. package/server/ui/custom-fields.js +252 -94
  45. package/server/ui/flow.css +326 -29
  46. package/server/ui/invoice-notes.js +2 -2
  47. package/server/ui/invoicing.js +92 -137
  48. package/server/ui/job-board.js +9 -7
  49. package/server/ui/job-change.js +23 -12
  50. package/server/ui/job-view.js +189 -31
  51. package/server/ui/jobs.js +738 -70
  52. package/server/ui/price-list.js +43 -86
  53. package/server/ui/price-match.js +93 -0
  54. package/server/ui/price-reconcile.js +357 -0
  55. package/server/ui/reconcile.js +275 -0
  56. package/server/ui/routes.js +1 -1
  57. package/server/ui/xero-import.js +329 -0
package/server/ui/jobs.js CHANGED
@@ -12,20 +12,24 @@
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
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, FILTER_FIELDS, filterCount, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
23
+ normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
21
24
  } from '/assets/ui/job-view.js';
22
25
 
23
26
  const LAYOUT_KEY = 'flow_jobs_layout';
27
+ const FOLDED_KEY = 'flow_jobs_folded';
24
28
  const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
25
29
  // A job's events (server/core/ledger.js), in plain words.
26
30
  const ACTIVITY = {
27
31
  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',
32
+ in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused', invoice_linked: 'Linked to an invoice',
29
33
  accounting_checked: 'Checked in the accounting system', payment_recorded: 'Payment recorded', paid: 'Paid in full',
30
34
  held: 'Put on hold', review: 'Sent to review', released: 'Hold removed',
31
35
  status_moved_back: 'The job status moved back in the lab system', source_lines_used: 'New job lines used on the draft',
@@ -39,6 +43,23 @@ const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4
39
43
 
40
44
  const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
41
45
  const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
46
+ // A list date, short: Today, Tomorrow or Yesterday, else day/month (and
47
+ // year when it is not this year).
48
+ const shortDate = (iso) => {
49
+ if (!iso) return '—';
50
+ const at = new Date(`${iso}T00:00:00`);
51
+ const now = new Date();
52
+ const days = Math.round((at - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
53
+ if (days === 0) return 'Today';
54
+ if (days === 1) return 'Tomorrow';
55
+ if (days === -1) return 'Yesterday';
56
+ return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
57
+ };
58
+ // A person's badge: their initials in a circle; the name on hover.
59
+ const initialsOf = (name) => {
60
+ const parts = (name ?? '?').split(/[\s._-]+/).filter(Boolean);
61
+ return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
62
+ };
42
63
  const clientOf = (job) => job.clientName ?? 'No client';
43
64
  // The one date a card shows: when the job is due, else when it was ordered.
44
65
  const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
@@ -51,6 +72,18 @@ function saveLayout(layout) {
51
72
  try { localStorage.setItem(LAYOUT_KEY, layout); } catch { /* the choice is only a convenience */ }
52
73
  }
53
74
 
75
+ // The list's folded groups, by group key, in this browser only.
76
+ function readFolded() {
77
+ try {
78
+ const value = JSON.parse(localStorage.getItem(FOLDED_KEY) ?? '{}');
79
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
80
+ } catch { return {}; }
81
+ }
82
+
83
+ function saveFolded(folded) {
84
+ try { localStorage.setItem(FOLDED_KEY, JSON.stringify(folded)); } catch { /* a convenience only */ }
85
+ }
86
+
54
87
  // The local calendar day, as yyyy-mm-dd, to mark late jobs.
55
88
  const today = () => {
56
89
  const now = new Date();
@@ -61,8 +94,10 @@ const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="
61
94
  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
95
  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
96
  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>';
97
+ 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
98
  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
99
  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>';
100
+ 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
101
  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
102
 
68
103
  export async function renderJobs(main, ui) {
@@ -81,16 +116,21 @@ export async function renderJobs(main, ui) {
81
116
  fields: [], systemFields: [], people: [], values: new Map(),
82
117
  // Each work system's "Ask before stage moves", read on the first move.
83
118
  askBefore: new Map(),
119
+ // The list's ticked jobs (by jobKey), and its folded groups by group key.
120
+ ticked: new Set(), folded: readFolded(),
84
121
  };
85
122
  const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
86
123
  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
124
  const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
88
125
  const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
126
+ // In the list, ticked rows get the same floating bar as Invoices.
127
+ const bulk = bulkBar(ui, { label: 'Ticked jobs' });
89
128
  main.classList.add('fx-main--wide');
90
129
  main.append(
91
130
  pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
92
131
  h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, search, toggle)),
93
- host);
132
+ host,
133
+ bulk.element);
94
134
 
95
135
  const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
96
136
  type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
@@ -100,9 +140,19 @@ export async function renderJobs(main, ui) {
100
140
  search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
101
141
 
102
142
  const problemsOf = (job) => fixes(job.obligation);
143
+ const jobKey = (job) => `${job.sourceSystem}\u0000${job.sourceJobRef}`;
103
144
  const valueKey = (job, fieldId) => `${job.sourceSystem}\u0000${job.sourceJobRef}\u0000${fieldId}`;
104
145
  const valueOf = (job, fieldId) => state.values.get(valueKey(job, fieldId)) ?? null;
105
146
  const groupable = () => state.fields.filter((f) => f.groupable);
147
+ // The Assignee: the lab's first person custom field (Flow makes one,
148
+ // server/core/custom-fields.js ensureAssignee). The filter "Assignee is
149
+ // Me" keeps the jobs it names the person looking at.
150
+ const assigneeField = () => state.fields.find((f) => f.type === 'person') ?? null;
151
+ // The list's columns a person can choose: Patient only when the
152
+ // installation keeps patient names; Assignee only with a person field.
153
+ const builtInColumns = () => Object.keys(COLUMNS).filter((id) => (id !== 'patient' || state.jobs.some((job) => job.patient))
154
+ && (id !== 'assignee' || assigneeField()));
155
+ const customColumns = () => state.fields.filter((f) => f.id !== assigneeField()?.id);
106
156
  const fieldOfGroup = (group) => groupable().find((f) => f.id === fieldIdOf(group)) ?? null;
107
157
  // A work system whose stages Flow can hand a move to (Seazona).
108
158
  const movesStages = (job) => state.systemFields.some((f) => f.source === job.sourceSystem);
@@ -127,13 +177,18 @@ export async function renderJobs(main, ui) {
127
177
  }, 500);
128
178
  }
129
179
 
130
- // One toolbar control: a button that opens a small menu under it. The menu
131
- // is a popover, so a click outside or Escape closes it.
180
+ // One toolbar control: a chip that opens a small panel under it, and an ×
181
+ // that clears what the chip sets (as ClickUp's "Clear all filters"). The
182
+ // panel is a popover, so a click outside or Escape closes it.
132
183
  let menuCount = 0;
133
- function menu(icon, build) {
184
+ function menu(icon, build, { rows = false, clearLabel = 'Clear' } = {}) {
134
185
  const id = `fx-jobs-menu-${++menuCount}`;
135
- const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
136
- const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' });
186
+ const panel = h('div', { class: `fx-pop${rows ? ' fx-pop--rows' : ''}`, id, popover: 'auto', role: rows ? 'dialog' : 'menu' });
187
+ const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': rows ? 'dialog' : 'menu', 'aria-expanded': 'false' });
188
+ const clear = h('button', { type: 'button', class: 'fx-chip__clear', 'aria-label': clearLabel, title: clearLabel, hidden: true }, lucide(h, 'x'));
189
+ const wrap = h('span', { class: 'fx-chipset' }, button, clear);
190
+ let onClear = null;
191
+ clear.addEventListener('click', () => onClear?.());
137
192
  const fill = () => panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
138
193
  panel.addEventListener('toggle', (event) => {
139
194
  button.setAttribute('aria-expanded', String(event.newState === 'open'));
@@ -144,10 +199,15 @@ export async function renderJobs(main, ui) {
144
199
  panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
145
200
  });
146
201
  return {
147
- node: [button, panel],
148
- set(label, on) {
202
+ node: [wrap, panel],
203
+ wrap,
204
+ fill: () => { if (panel.matches(':popover-open')) fill(); },
205
+ set(label, on, clearIt = null) {
149
206
  button.replaceChildren(svg(icon), h('span', {}, label));
207
+ wrap.classList.toggle('fx-chipset--on', on);
150
208
  button.classList.toggle('fx-chip--on', on);
209
+ onClear = clearIt;
210
+ clear.hidden = !clearIt;
151
211
  },
152
212
  };
153
213
  }
@@ -156,55 +216,257 @@ export async function renderJobs(main, ui) {
156
216
  const option = (text, checked, onclick, role = 'menuitemradio') => h('button', {
157
217
  type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
158
218
  }, h('span', { class: 'fx-pop__tick' }, checked ? svg(TICK_ICON) : null), h('span', {}, text));
159
- 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'] };
161
-
162
- const groupMenu = menu(GROUP_ICON, (_redraw, close) => [
163
- heading('Group by'),
164
- ...Object.entries(GROUPS).map(([id, text]) => option(text, state.view.group === id, () => { change({ group: id }); close(); })),
165
- groupable().length ? h('div', { class: 'fx-pop__sep' }) : null,
166
- groupable().length ? heading('Custom fields') : null,
167
- ...groupable().map((f) => option(f.name, state.view.group === fieldGroup(f.id), () => { change({ group: fieldGroup(f.id) }); close(); })),
168
- ]);
219
+ const DATE_WORDS = ['Oldest first', 'Newest first'];
220
+ const NUMBER_WORDS = ['Low to high', 'High to low'];
221
+ const DIR_WORDS = { total: NUMBER_WORDS, due: DATE_WORDS, ordered: DATE_WORDS, shipped: DATE_WORDS };
222
+ const TYPE_WORDS = { date: DATE_WORDS, number: NUMBER_WORDS };
223
+ const dirWords = (by) => DIR_WORDS[by] ?? TYPE_WORDS[fieldById(fieldIdOf(by))?.type] ?? ['A to Z', 'Z to A'];
224
+ const fieldById = (id) => state.fields.find((f) => f.id === id) ?? null;
225
+
226
+ // The parts of a row panel (Group by, Sort, Filters): a title, rows of
227
+ // selects with a bin at the end, and a foot with "Add" and "Clear all".
228
+ const title = (text) => h('p', { class: 'fx-rows__title' }, text);
229
+ const choose = (label, value, options, onchange, extra = {}) => h('select', {
230
+ class: 'fx-select fx-select--sm', 'aria-label': label, onchange: (event) => onchange(event.target.value), ...extra,
231
+ }, options.map((o) => (o.group
232
+ ? (o.options.length ? h('optgroup', { label: o.group }, o.options.map((x) => h('option', { value: x.value, selected: x.value === value }, x.label))) : null)
233
+ : h('option', { value: o.value, selected: o.value === value, disabled: o.disabled }, o.label))));
234
+ const bin = (label, onclick) => h('button', { type: 'button', class: 'fx-rows__bin', 'aria-label': label, title: label, onclick }, lucide(h, 'trash-2'));
235
+ const addButton = (text, onclick) => h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm fx-rows__add', onclick }, lucide(h, 'plus'), text);
236
+ const rowsFoot = (...parts) => h('div', { class: 'fx-rows__foot' }, ...parts);
237
+ const clearAll = (onclick) => h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick }, 'Clear all');
238
+
239
+ // ── Group by ──
240
+ // One row: what to group by, the group order, and a bin that takes the
241
+ // groups away (the list only: the board always has lanes).
242
+ const groupOptions = () => [
243
+ ...Object.entries(GROUPS).filter(([id]) => id !== 'none').map(([value, label]) => ({ value, label })),
244
+ { group: 'Custom fields', options: groupable().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
245
+ ];
246
+ const groupMenu = menu(GROUP_ICON, (redraw) => {
247
+ const list = state.view.layout === 'list';
248
+ const group = state.view.group === 'none' && !list ? 'status' : state.view.group;
249
+ const none = group === 'none';
250
+ return [
251
+ title('Group by'),
252
+ h('div', { class: 'fx-rows' }, h('div', { class: 'fx-rows__row fx-rows__row--group' },
253
+ choose('Group by', none ? '' : group, [...(none ? [{ value: '', label: 'Choose…', disabled: true }] : []), ...groupOptions()], (value) => { change({ group: value }); redraw(); }),
254
+ choose('Group order', state.view.groupDir, [{ value: 'asc', label: 'Ascending' }, { value: 'desc', label: 'Descending' }], (value) => { change({ groupDir: value }); redraw(); }, { disabled: none }),
255
+ list && !none ? bin('Remove the grouping', () => { change({ group: 'none' }); redraw(); }) : h('span', { class: 'fx-rows__gap' }))),
256
+ ];
257
+ }, { rows: true, clearLabel: 'Remove the grouping' });
258
+
259
+ // ── Sort ──
260
+ // Rows of sorts, each applied in turn. In the list, a column's head sorts
261
+ // too.
262
+ const sortOptions = () => [
263
+ ...Object.entries(SORTS).map(([value, label]) => ({ value, label })),
264
+ { group: 'Custom fields', options: state.fields.map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
265
+ ];
266
+ const setSorts = (sorts) => change({ sorts });
169
267
  const sortMenu = menu(SORT_ICON, (redraw) => {
170
- const [up, down] = DIR_WORDS[state.view.sort] ?? ['A to Z', 'Z to A'];
268
+ const { sorts } = state.view;
269
+ const unused = sortOptions().flatMap((o) => o.options ?? [o]).filter((o) => !sorts.some((s) => s.by === o.value));
171
270
  return [
172
- heading('Sort by'),
173
- ...Object.entries(SORTS).map(([id, text]) => option(text, state.view.sort === id, () => { change({ sort: id }); redraw(); })),
174
- h('div', { class: 'fx-pop__sep' }),
175
- option(up, state.view.dir === 'asc', () => { change({ dir: 'asc' }); redraw(); }),
176
- option(down, state.view.dir === 'desc', () => { change({ dir: 'desc' }); redraw(); }),
271
+ title('Sort'),
272
+ sorts.length ? h('div', { class: 'fx-rows' }, sorts.map((s, i) => {
273
+ const [up, down] = dirWords(s.by);
274
+ const others = sorts.filter((_, n) => n !== i).map((x) => x.by);
275
+ const options = sortOptions().map((o) => (o.group ? { ...o, options: o.options.filter((x) => !others.includes(x.value)) } : o)).filter((o) => o.group || !others.includes(o.value));
276
+ return h('div', { class: 'fx-rows__row fx-rows__row--sort' },
277
+ h('span', { class: 'fx-rows__lead' }, i ? 'then' : 'Sort by'),
278
+ choose('Sort by', s.by, options, (by) => { setSorts(sorts.map((x, n) => (n === i ? { by, dir: x.dir } : x))); redraw(); }),
279
+ choose('Order', s.dir, [{ value: 'asc', label: up }, { value: 'desc', label: down }], (dir) => { setSorts(sorts.map((x, n) => (n === i ? { ...x, dir } : x))); redraw(); }),
280
+ bin('Remove this sort', () => { setSorts(sorts.filter((_, n) => n !== i)); redraw(); }));
281
+ })) : h('p', { class: 'fx-rows__empty' }, 'No sort. Jobs show in the lab system’s order.'),
282
+ rowsFoot(
283
+ sorts.length < 5 && unused.length ? addButton('Add sort', () => { setSorts([...sorts, { by: unused[0].value, dir: 'asc' }]); redraw(); }) : h('span'),
284
+ sorts.length ? clearAll(() => { setSorts([]); redraw(); }) : null),
177
285
  ];
178
- });
286
+ }, { rows: true, clearLabel: 'Clear the sort' });
287
+
288
+ // ── Filters ──
289
+ // Rows of field, operator and value, with a bin on each, "Add filter",
290
+ // and "Clear all". The second row's And / Or applies to every row.
291
+ const filterFields = () => [
292
+ ...Object.entries(FILTER_FIELDS).filter(([id]) => id !== 'assignee' || assigneeField())
293
+ .map(([value, f]) => ({ value, label: value === 'assignee' ? assigneeField().name : f.label })),
294
+ { group: 'Custom fields', options: customColumns().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
295
+ ];
296
+ const kindOf = (field) => {
297
+ if (field === 'assignee') return assigneeField() ? 'person' : null;
298
+ if (Object.hasOwn(FILTER_FIELDS, field)) return FILTER_FIELDS[field].kind;
299
+ return KIND_OF_TYPE[fieldById(fieldIdOf(field))?.type] ?? null;
300
+ };
301
+ const PROBLEM_OPS = { yes: 'has one', no: 'has none' };
302
+ const opsOf = (field) => (field === 'problem' ? PROBLEM_OPS : OPERATORS[kindOf(field)] ?? {});
303
+ // The choices a choice or person filter offers: [{ value, label }].
304
+ function choicesOf(field) {
305
+ const people = () => [{ value: 'me', label: 'Me' }, ...state.people.map((p) => ({ value: p.id, label: p.name }))];
306
+ const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
307
+ if (field === 'status') return orderStatuses(state.statuses, state.jobs).map((v) => ({ value: v, label: v }));
308
+ if (field === 'stage') return Object.values(STAGE_GROUPS).map((v) => ({ value: v, label: v }));
309
+ if (field === 'client') return labels(state.jobs.map(clientOf));
310
+ if (field === 'practitioner') return labels(state.jobs.map((job) => job.practitioner));
311
+ if (field === 'assignee') return people();
312
+ const custom = fieldById(fieldIdOf(field));
313
+ if (custom?.type === 'person') return people();
314
+ return (custom?.options ?? []).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name }));
315
+ }
316
+ // A job's value for a filter field, as the filter compares it.
317
+ function filterValue(job, field) {
318
+ switch (field) {
319
+ case 'status': return statusOf(job);
320
+ case 'stage': return STAGE_GROUPS[invoiceStage(job.obligation) ?? 'in_work'];
321
+ case 'client': return clientOf(job);
322
+ case 'practitioner': return job.practitioner ?? null;
323
+ case 'assignee': return assigneeField() ? valueOf(job, assigneeField().id)?.value ?? null : null;
324
+ case 'due': return job.dueOn ?? null;
325
+ case 'ordered': return job.orderedOn ?? null;
326
+ case 'shipped': return job.shippedOn ?? null;
327
+ case 'total': return job.totalCents == null ? null : job.totalCents / 100;
328
+ case 'problem': return problemsOf(job).length > 0;
329
+ default: return valueOf(job, fieldIdOf(field))?.value ?? null;
330
+ }
331
+ }
332
+ // A row's value when its operator changes: kept when it still fits.
333
+ function valueFor(field, op, old) {
334
+ const kind = kindOf(field);
335
+ if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return Array.isArray(old) ? old : [];
336
+ if (['before', 'after', 'on'].includes(op)) return typeof old === 'string' && /^\d{4}-/.test(old) ? old : today();
337
+ if (['eq', 'gt', 'lt'].includes(op)) return typeof old === 'number' ? old : null;
338
+ if (['has', 'is'].includes(op)) return typeof old === 'string' ? old : '';
339
+ return null;
340
+ }
341
+ const setFilters = (filters) => change({ filters });
342
+ const setRow = (i, patch) => setFilters(state.view.filters.map((f, n) => (n === i ? { ...f, ...patch } : f)));
343
+ // A choice row's value: a button that opens a list of choices to tick,
344
+ // with a search when the list is long.
345
+ function choiceValue(i, filter) {
346
+ const choices = choicesOf(filter.field);
347
+ const named = () => {
348
+ const on = choices.filter((c) => filter.value.includes(c.value)).map((c) => c.label);
349
+ return on.length ? on.join(', ') : 'Choose…';
350
+ };
351
+ const id = `fx-jobs-choice-${i}`;
352
+ const button = h('button', { type: 'button', class: 'fx-select fx-select--sm fx-rows__value', popovertarget: id, title: named() }, named());
353
+ const pop = h('div', { class: 'fx-pop fx-pop--choices', id, popover: 'auto', role: 'menu' });
354
+ const find = choices.length > 8 ? h('input', { class: 'fx-input fx-input--sm', type: 'search', placeholder: 'Find', 'aria-label': 'Find a choice' }) : null;
355
+ const items = h('div', {});
356
+ const drawItems = () => {
357
+ const q = find?.value.trim().toLowerCase() ?? '';
358
+ items.replaceChildren(...choices.filter((c) => !q || c.label.toLowerCase().includes(q)).map((c) => option(c.label, filter.value.includes(c.value), () => {
359
+ filter = { ...filter, value: filter.value.includes(c.value) ? filter.value.filter((v) => v !== c.value) : [...filter.value, c.value] };
360
+ setRow(i, { value: filter.value });
361
+ button.textContent = named();
362
+ button.title = named();
363
+ drawItems();
364
+ }, 'menuitemcheckbox')));
365
+ };
366
+ find?.addEventListener('input', drawItems);
367
+ pop.append(...[find, items].filter(Boolean));
368
+ pop.addEventListener('toggle', (event) => {
369
+ if (event.newState !== 'open') return;
370
+ drawItems();
371
+ const at = button.getBoundingClientRect();
372
+ pop.style.top = `${Math.round(at.bottom + 4)}px`;
373
+ pop.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - pop.offsetWidth - 8)))}px`;
374
+ find?.focus();
375
+ });
376
+ return [button, pop];
377
+ }
378
+ function valueControl(i, filter) {
379
+ const kind = kindOf(filter.field);
380
+ const { op } = filter;
381
+ if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return choiceValue(i, filter);
382
+ if (['before', 'after', 'on'].includes(op)) {
383
+ return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'date', 'aria-label': 'Date', value: filter.value ?? '', onchange: (event) => setRow(i, { value: event.target.value || null }) });
384
+ }
385
+ if (['eq', 'gt', 'lt'].includes(op)) {
386
+ return h('input', {
387
+ class: 'fx-input fx-input--sm fx-rows__value', type: 'number', step: 'any', inputmode: 'decimal', 'aria-label': 'Number', value: filter.value ?? '',
388
+ oninput: (event) => setRow(i, { value: event.target.value === '' ? null : Number(event.target.value) }),
389
+ });
390
+ }
391
+ if (['has', 'is'].includes(op)) {
392
+ return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'text', 'aria-label': 'Text', value: filter.value ?? '', oninput: (event) => setRow(i, { value: event.target.value }) });
393
+ }
394
+ return h('span', { class: 'fx-rows__value fx-rows__value--none' });
395
+ }
179
396
  const filterMenu = menu(FILTER_ICON, (redraw) => {
180
- const hidden = new Set(state.view.hide);
397
+ const { filters, match } = state.view;
398
+ const fields = filterFields();
181
399
  return [
182
- heading('Show only'),
183
- option('Late jobs', state.view.late, () => { change({ late: !state.view.late }); redraw(); }, 'menuitemcheckbox'),
184
- option('Jobs with a problem', state.view.problems, () => { change({ problems: !state.view.problems }); redraw(); }, 'menuitemcheckbox'),
185
- h('div', { class: 'fx-pop__sep' }),
186
- heading('Statuses'),
187
- ...orderStatuses(state.statuses, state.jobs).map((label) => option(label, !hidden.has(label), () => {
188
- const next = new Set(hidden);
189
- if (next.has(label)) next.delete(label); else next.add(label);
190
- change({ hide: [...next] });
191
- redraw();
192
- }, 'menuitemcheckbox')),
193
- 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(); },
195
- }, 'Clear filters')) : null,
400
+ title('Filters'),
401
+ filters.length ? h('div', { class: 'fx-rows' }, filters.map((filter, i) => {
402
+ const known = kindOf(filter.field);
403
+ const lead = i === 0 ? h('span', { class: 'fx-rows__lead' }, 'Where')
404
+ : i === 1 ? choose('Match', match, [{ value: 'all', label: 'And' }, { value: 'any', label: 'Or' }], (value) => { change({ match: value }); redraw(); })
405
+ : h('span', { class: 'fx-rows__lead' }, match === 'any' ? 'Or' : 'And');
406
+ if (!known) {
407
+ return h('div', { class: 'fx-rows__row' }, lead, h('span', { class: 'fx-rows__gone' }, 'A custom field that was deleted'), bin('Remove this filter', () => { setFilters(filters.filter((_, n) => n !== i)); redraw(); }));
408
+ }
409
+ const ops = opsOf(filter.field);
410
+ return h('div', { class: 'fx-rows__row' },
411
+ lead,
412
+ choose('Field', filter.field, fields, (field) => {
413
+ const op = Object.keys(opsOf(field))[0];
414
+ setRow(i, { field, op, value: valueFor(field, op, null) });
415
+ redraw();
416
+ }),
417
+ choose('Operator', filter.op, Object.entries(ops).map(([value, label]) => ({ value, label })), (op) => { setRow(i, { op, value: valueFor(filter.field, op, filter.value) }); redraw(); }),
418
+ valueControl(i, filter),
419
+ bin('Remove this filter', () => { setFilters(filters.filter((_, n) => n !== i)); redraw(); }));
420
+ })) : h('p', { class: 'fx-rows__empty' }, 'No filters. Every job shows.'),
421
+ rowsFoot(
422
+ filters.length < 20 ? addButton('Add filter', () => { setFilters([...filters, { field: 'status', op: 'is', value: [] }]); redraw(); }) : h('span'),
423
+ filters.length ? clearAll(() => { setFilters([]); redraw(); }) : null),
424
+ ];
425
+ }, { rows: true, clearLabel: 'Clear all filters' });
426
+
427
+ // Columns (the list only): Client always shows; each other column can be
428
+ // shown or hidden. Custom fields are columns too.
429
+ const heading = (text) => h('p', { class: 'fx-pop__head' }, text);
430
+ const columnsMenu = menu(COLUMNS_ICON, (redraw) => {
431
+ const on = new Set(state.view.columns);
432
+ const flip = (id) => {
433
+ const next = on.has(id) ? state.view.columns.filter((c) => c !== id) : [...state.view.columns, id];
434
+ change({ columns: next });
435
+ redraw();
436
+ };
437
+ const custom = customColumns();
438
+ return [
439
+ heading('Columns'),
440
+ option('Client', true, () => {}, 'menuitemcheckbox'),
441
+ ...builtInColumns().map((id) => option(COLUMNS[id], on.has(id), () => flip(id), 'menuitemcheckbox')),
442
+ custom.length ? h('div', { class: 'fx-pop__sep' }) : null,
443
+ custom.length ? heading('Custom fields') : null,
444
+ ...custom.map((f) => option(f.name, on.has(fieldGroup(f.id)), () => flip(fieldGroup(f.id)), 'menuitemcheckbox')),
196
445
  ];
197
446
  });
198
- controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node);
447
+ controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node, ...columnsMenu.node);
199
448
 
449
+ // A sort's name: a built-in key or a custom field.
450
+ const sortName = (by) => SORTS[by] ?? fieldById(fieldIdOf(by))?.name ?? 'Custom field';
200
451
  function drawControls() {
201
- const { group, sort, dir } = state.view;
452
+ const { group, sorts, layout } = state.view;
453
+ const list = layout === 'list';
202
454
  const field = fieldOfGroup(group);
203
- groupMenu.set(`Group: ${field?.name ?? GROUPS[group] ?? GROUPS.status}`, group !== DEFAULT_VIEW.group && (Boolean(field) || Boolean(GROUPS[group])));
204
- const sorted = sort !== DEFAULT_VIEW.sort || dir !== DEFAULT_VIEW.dir;
205
- sortMenu.set(sorted ? `Sort: ${SORTS[sort]}${dir === 'desc' ? ' ↓' : ''}` : 'Sort', sorted);
455
+ // The board has no "None": it shows the status lanes.
456
+ const shown = group === 'none' && !list ? 'status' : group;
457
+ const label = shown === 'none' ? 'Group: None' : `Group: ${field?.name ?? GROUPS[shown] ?? GROUPS.status}`;
458
+ const grouped = list ? shown !== 'none' : shown !== 'status';
459
+ groupMenu.set(label, shown !== DEFAULT_VIEW.group, grouped ? () => change({ group: list ? 'none' : 'status' }) : null);
460
+ // In the list, the column heads sort; the Sort chip shows there only
461
+ // while a sort is on, so it can be seen and cleared.
462
+ const [first] = sorts;
463
+ sortMenu.set(sorts.length > 1 ? `Sort · ${sorts.length}` : first ? `Sort: ${sortName(first.by)} ${first.dir === 'desc' ? '↓' : '↑'}` : 'Sort', sorts.length > 0,
464
+ sorts.length ? () => change({ sorts: [] }) : null);
465
+ sortMenu.wrap.hidden = list && !sorts.length;
206
466
  const count = filterCount(state.view);
207
- filterMenu.set(count ? `Filter · ${count}` : 'Filter', count > 0);
467
+ filterMenu.set(count ? `${count} ${count === 1 ? 'Filter' : 'Filters'}` : 'Filter', count > 0, state.view.filters.length ? () => change({ filters: [], match: 'all' }) : null);
468
+ columnsMenu.set('Columns', false);
469
+ columnsMenu.wrap.hidden = !list;
208
470
  }
209
471
  drawControls();
210
472
 
@@ -217,10 +479,15 @@ export async function renderJobs(main, ui) {
217
479
  return;
218
480
  }
219
481
  const found = filterJobs(state.jobs.filter((job) => matches(job, state.q)), state.view,
220
- { status: statusOf, late: isLate, problem: (job) => problemsOf(job).length > 0 });
221
- const groups = grouped(sortJobs(found, state.view));
482
+ { valueOf: filterValue, kindOf, late: isLate, me: ui.me?.person?.id ?? null, today: today() });
483
+ const groups = grouped(sortJobs(found, state.view, sortKey));
222
484
  main.classList.toggle('fx-main--board', state.view.layout === 'board');
485
+ // A tick on a job the search or filter now hides goes.
486
+ const shown = new Set(found.map(jobKey));
487
+ for (const key of state.ticked) if (!shown.has(key)) state.ticked.delete(key);
488
+ state.found = found;
223
489
  host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
490
+ drawBulk();
224
491
  }
225
492
 
226
493
  async function load() {
@@ -296,7 +563,12 @@ export async function renderJobs(main, ui) {
296
563
  // A group's `drop` says what a card dropped in it changes: a custom
297
564
  // field's value, or the job's stage in the work system. No `drop`: cards
298
565
  // cannot be dropped there (invoice stage, client, practitioner).
566
+ // "Descending" shows the groups in the other order.
299
567
  function grouped(jobs) {
568
+ const groups = groupsOf(jobs);
569
+ return state.view.groupDir === 'desc' ? groups.reverse() : groups;
570
+ }
571
+ function groupsOf(jobs) {
300
572
  const { group } = state.view;
301
573
  const field = fieldOfGroup(group);
302
574
  if (field) {
@@ -308,9 +580,9 @@ export async function renderJobs(main, ui) {
308
580
  return { ...g, rank: 'plain', color: lane?.color, drop: lane && !lane.archived ? { kind: 'field', field, value: lane.value } : null };
309
581
  });
310
582
  }
311
- if (group === 'status' || !GROUPS[group]) {
312
- const hidden = new Set(state.view.hide);
313
- return groupJobs(jobs, orderStatuses(state.statuses, state.jobs).filter((label) => !hidden.has(label)), laneStatusOf)
583
+ if (group === 'none' && state.view.layout === 'list') return [{ label: null, jobs, rank: 'plain' }];
584
+ if (group === 'status' || group === 'none' || !GROUPS[group]) {
585
+ return groupJobs(jobs, keptStatuses(state.view, orderStatuses(state.statuses, state.jobs)), laneStatusOf)
314
586
  .map((g) => ({ ...g, rank: statusRank(g.label), drop: g.label === NO_STATUS ? null : { kind: 'stage', status: g.label } }));
315
587
  }
316
588
  if (group === 'stage') {
@@ -569,27 +841,394 @@ export async function renderJobs(main, ui) {
569
841
  return h('div', { class: 'fx-task__move' }, wait, select);
570
842
  }
571
843
 
844
+ // A column's menu (a click on its head, or a right-click): sort by it,
845
+ // move it, hide it. Client always shows. `column` null is the + menu: the
846
+ // hidden columns, to add one back.
847
+ // What a column's head sorts by: a built-in key, or the column's custom
848
+ // field (Assignee is the lab's person field).
849
+ const SORT_OF = { client: 'client', practitioner: 'practitioner', due: 'due', ordered: 'ordered', shipped: 'shipped', total: 'total' };
850
+ const sortOf = (columnId) => SORT_OF[columnId]
851
+ ?? (columnId === 'assignee' && assigneeField() ? fieldGroup(assigneeField().id) : fieldIdOf(columnId) ? columnId : null);
852
+ // A custom field's sort key: a person's name, an option's place in the
853
+ // list, ticked first or last, or the value.
854
+ function sortKey(job, by) {
855
+ const field = fieldById(fieldIdOf(by));
856
+ const value = field ? valueOf(job, field.id)?.value ?? null : null;
857
+ if (!field || value == null) return null;
858
+ if (field.type === 'person') return (state.people.find((p) => p.id === value)?.name ?? '').toLowerCase() || null;
859
+ if (field.type === 'dropdown') {
860
+ const at = (field.options ?? []).findIndex((o) => o.id === value);
861
+ return at < 0 ? null : at;
862
+ }
863
+ if (field.type === 'checkbox') return value === true ? 1 : 0;
864
+ return typeof value === 'string' ? value.toLowerCase() : value;
865
+ }
866
+ // A column head's sort button, as ClickUp's: it shows on hover, or
867
+ // always while it sorts. A click sorts one way, again the other way, and
868
+ // a third time takes the sort off. A right-click on a sorting head opens
869
+ // a small menu: change the direction, or clear the sort.
870
+ function sortButton(columnId, label) {
871
+ const by = sortOf(columnId);
872
+ if (!by) return null;
873
+ const { sorts } = state.view;
874
+ const at = sorts.findIndex((s) => s.by === by);
875
+ const on = at >= 0 ? sorts[at] : null;
876
+ const [up, down] = dirWords(by);
877
+ const next = () => {
878
+ if (!on) change({ sorts: [{ by, dir: 'asc' }] });
879
+ else if (on.dir === 'asc') change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: 'desc' } : s)) });
880
+ else change({ sorts: sorts.filter((_, n) => n !== at) });
881
+ };
882
+ const words = on ? `Sorted ${on.dir === 'asc' ? up : down}. Click to ${on.dir === 'asc' ? `sort ${down}` : 'clear the sort'}` : `Sort by ${label}`;
883
+ return h('button', {
884
+ type: 'button', class: `fx-th-sort${on ? ' fx-th-sort--on' : ''}`, 'aria-label': words, title: words,
885
+ onclick: (event) => { event.stopPropagation(); next(); },
886
+ oncontextmenu: (event) => {
887
+ if (!on) return;
888
+ event.preventDefault();
889
+ event.stopPropagation();
890
+ onRightButton(event, () => sortMenuAt(event, by, label, on, at));
891
+ },
892
+ }, lucide(h, on ? (on.dir === 'asc' ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'),
893
+ on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
894
+ }
895
+ function sortMenuAt(event, by, label, on, at) {
896
+ const close = () => columnPop.hidePopover?.();
897
+ const { sorts } = state.view;
898
+ const flip = () => change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)) });
899
+ columnPop.replaceChildren(
900
+ h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { close(); flip(); } },
901
+ h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${label}`), h('span', { class: 'fx-pop__end' }, svg(TICK_ICON))),
902
+ h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ sorts: sorts.filter((_, n) => n !== at) }); } },
903
+ h('span', { class: 'fx-pop__tick' }, lucide(h, 'ban')), h('span', {}, 'Clear sort')));
904
+ placeColumnPop(event);
905
+ }
906
+ const columnPop = h('div', { class: 'fx-pop', id: 'fx-jobs-colmenu', popover: 'auto', role: 'menu' });
907
+ document.body.append(columnPop);
908
+ // A popover opened while the right button is still down closes again on
909
+ // its release (light dismiss), so open it after the release.
910
+ function onRightButton(event, open) {
911
+ if (event.buttons & 2) addEventListener('pointerup', () => setTimeout(open), { once: true });
912
+ else open();
913
+ }
914
+ function columnMenu(column, event) {
915
+ const ids = state.view.columns;
916
+ const close = () => columnPop.hidePopover?.();
917
+ const item = (icon, text, run) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); run(); } },
918
+ h('span', { class: 'fx-pop__tick' }, lucide(h, icon)), h('span', {}, text));
919
+ const parts = [];
920
+ if (column) {
921
+ const sort = sortOf(column.id);
922
+ if (sort) {
923
+ const [up, down] = dirWords(sort);
924
+ parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sorts: [{ by: sort, dir: 'asc' }] })),
925
+ item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sorts: [{ by: sort, dir: 'desc' }] })), h('div', { class: 'fx-pop__sep' }));
926
+ }
927
+ const rest = ids.filter((c) => c !== column.id);
928
+ parts.push(item('arrow-left-to-line', 'Move to start', () => change({ columns: [column.id, ...rest] })),
929
+ item('arrow-right-to-line', 'Move to end', () => change({ columns: [...rest, column.id] })));
930
+ if (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)) {
931
+ parts.push(item('settings', 'Edit custom field', () => ui.go('/custom-fields')));
932
+ }
933
+ parts.push(h('div', { class: 'fx-pop__sep' }), item('eye-off', 'Hide column', () => change({ columns: rest })));
934
+ }
935
+ // + lists the hidden columns: a click adds one at the end.
936
+ if (!column) {
937
+ const shown = new Set(ids);
938
+ const hidden = [
939
+ ...builtInColumns().filter((id) => !shown.has(id)).map((id) => ({ id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id] })),
940
+ ...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
941
+ ];
942
+ parts.push(...hidden.map((c) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ columns: [...ids, c.id] }); } }, c.label)));
943
+ if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
944
+ }
945
+ columnPop.replaceChildren(...parts);
946
+ placeColumnPop(event);
947
+ }
948
+ function placeColumnPop(event) {
949
+ columnPop.showPopover?.();
950
+ const x = event.clientX || event.currentTarget?.getBoundingClientRect?.().left || 0;
951
+ const y = event.clientY || event.currentTarget?.getBoundingClientRect?.().bottom || 0;
952
+ columnPop.style.top = `${Math.round(Math.min(y + 4, innerHeight - columnPop.offsetHeight - 8))}px`;
953
+ columnPop.style.left = `${Math.round(Math.max(8, Math.min(x, innerWidth - columnPop.offsetWidth - 8)))}px`;
954
+ }
955
+
956
+ // The list's columns, in the order the person chose them. One column holds
957
+ // one field; each date has its own column.
958
+ function listColumns() {
959
+ const day = (iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
960
+ const late = (job) => (isLate(job) ? h('span', { class: 'fx-job__date--late', title: 'Past due' }, day(job.dueOn)) : day(job.dueOn));
961
+ const BUILT_IN = {
962
+ case: { kind: 'case', dim: true, cell: (job) => caseNumber(job) ?? '—' },
963
+ practitioner: { kind: 'text', dim: true, cell: (job) => job.practitioner ?? '—' },
964
+ patient: { kind: 'text', dim: true, cell: (job) => job.patient ?? '—' },
965
+ stage: { kind: 'stage', cell: (job) => stagePill(statusOf(job)) },
966
+ assignee: { kind: 'person', cell: (job) => personBadge(job, assigneeField()) },
967
+ ordered: { kind: 'date', dim: true, cell: (job) => day(job.orderedOn) },
968
+ due: { kind: 'date', dim: true, cell: late },
969
+ shipped: { kind: 'date', dim: true, cell: (job) => day(job.shippedOn) },
970
+ total: { kind: 'total', num: true, strong: true, cell: (job) => (job.totalCents == null ? '—' : formatCents(job.totalCents)) },
971
+ };
972
+ const allowed = new Set(builtInColumns());
973
+ return state.view.columns.map((id) => {
974
+ if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
975
+ const field = customColumns().find((f) => fieldGroup(f.id) === id);
976
+ if (!field) return null;
977
+ const cell = field.type === 'person' ? (job) => personBadge(job, field)
978
+ : field.type === 'date' ? (job) => day(valueOf(job, field.id)?.value ?? null) : (job) => fieldText(job, field);
979
+ 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 };
980
+ }).filter(Boolean);
981
+ }
982
+
983
+ // A person custom field's value as a badge (initials), the name on hover.
984
+ function personBadge(job, field) {
985
+ const value = field ? valueOf(job, field.id)?.value ?? null : null;
986
+ if (!value) return h('span', { class: 'fx-dim' }, '—');
987
+ const name = state.people.find((p) => p.id === value)?.name ?? 'Former person';
988
+ return h('span', { class: 'fx-avatar fx-avatar--sm', role: 'img', 'aria-label': name, title: name }, initialsOf(name));
989
+ }
990
+
991
+ // A job's custom field value as text.
992
+ function fieldText(job, field) {
993
+ const value = field ? valueOf(job, field.id)?.value ?? null : null;
994
+ if (value == null || value === false) return '—';
995
+ if (field.type === 'checkbox') return 'Yes';
996
+ if (field.type === 'person') return state.people.find((p) => p.id === value)?.name ?? 'Former person';
997
+ if (field.type === 'dropdown') return field.options?.find((o) => o.id === value)?.name ?? '—';
998
+ if (field.type === 'date') return showDate(value);
999
+ return String(value);
1000
+ }
1001
+
572
1002
  // The list: one block per group, with the group's label over its rows.
1003
+ // A tick column at the left; a group's head has a tick for every job in
1004
+ // the group, and a click on the head folds the group or opens it. Grouped
1005
+ // by None, it is one plain list with no heads.
573
1006
  function list(groups, count) {
574
1007
  if (!count) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
1008
+ const shownGroups = groups.filter((g) => g.jobs.length);
1009
+ const folded = new Set(state.folded[state.view.group] ?? []);
1010
+ const allJobs = shownGroups.flatMap((g) => g.jobs);
1011
+ const boxes = [];
1012
+ // A tick box for a set of jobs: ticked when all are, half when some are.
1013
+ const setBox = (jobs, label) => {
1014
+ const box = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': label, onclick: (event) => event.stopPropagation() });
1015
+ const sync = () => {
1016
+ const on = jobs.filter((job) => state.ticked.has(jobKey(job))).length;
1017
+ box.checked = on > 0 && on === jobs.length;
1018
+ box.indeterminate = on > 0 && on < jobs.length;
1019
+ };
1020
+ box.addEventListener('change', () => {
1021
+ for (const job of jobs) { if (box.checked) state.ticked.add(jobKey(job)); else state.ticked.delete(jobKey(job)); }
1022
+ syncAll();
1023
+ });
1024
+ boxes.push(sync);
1025
+ sync();
1026
+ return box;
1027
+ };
1028
+ const syncAll = () => { for (const sync of boxes) sync(); drawBulk(); };
1029
+ const tickCell = (box) => h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, box);
1030
+ const columns = listColumns();
575
1031
  const row = (job) => h('tr', {
576
1032
  class: 'fx-row-link', tabindex: '0',
577
1033
  onclick: () => openJob(job),
578
- onkeydown: (event) => { if (event.key === 'Enter') openJob(job); },
1034
+ onkeydown: (event) => { if (event.key === 'Enter' && event.target === event.currentTarget) openJob(job); },
579
1035
  },
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)));
1036
+ tickCell(setBox([job], `Tick ${clientOf(job)}${caseNumber(job) ? `, case ${caseNumber(job)}` : ''}`)),
1037
+ h('td', { class: 'fx-c-client' }, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
1038
+ 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))),
1039
+ h('td', { class: 'fx-c-end' }));
1040
+ const fold = (label) => {
1041
+ const next = new Set(state.folded[state.view.group] ?? []);
1042
+ if (next.has(label)) next.delete(label); else next.add(label);
1043
+ state.folded = { ...state.folded, [state.view.group]: [...next] };
1044
+ saveFolded(state.folded);
1045
+ draw();
1046
+ };
1047
+ const head = ({ label, jobs, rank, color }) => {
1048
+ const open = !folded.has(label);
1049
+ const toggle = h('button', {
1050
+ 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`,
1051
+ onclick: (event) => { event.stopPropagation(); fold(label); },
1052
+ }, svg(CHEVRON_ICON),
1053
+ 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));
1054
+ return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: () => fold(label) },
1055
+ tickCell(setBox(jobs, `Tick every job in ${label}`)),
1056
+ h('td', { colspan: String(columns.length + 2) }, h('span', { class: 'fx-table__group-row' }, toggle, h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))))));
1057
+ };
585
1058
  return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
586
1059
  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)))));
1060
+ h('th', { class: 'fx-tick-cell' }, setBox(allJobs, 'Tick every job shown')),
1061
+ h('th', {}, h('span', { class: 'fx-th-cell' }, h('span', { class: 'fx-th fx-th--fixed' }, 'Client'), sortButton('client', 'Client'))),
1062
+ columns.map((c) => h('th', { class: c.num ? 'fx-num' : null, oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => columnMenu(c, event)); } },
1063
+ h('span', { class: 'fx-th-cell' },
1064
+ h('button', { type: 'button', class: 'fx-th', 'aria-haspopup': 'menu', title: 'Column menu (or right-click)', onclick: (event) => columnMenu(c, event) }, c.label),
1065
+ sortButton(c.id, c.label)))),
1066
+ h('th', { class: 'fx-th-add' }, h('button', {
1067
+ type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => columnMenu(null, event),
1068
+ }, '+')))),
1069
+ shownGroups.map((g) => h('tbody', {},
1070
+ g.label == null ? null : head(g),
1071
+ g.label != null && folded.has(g.label) ? null : g.jobs.map(row)))));
1072
+ }
1073
+
1074
+ // ── Bulk actions ─────────────────────────────────────────────────────────
1075
+ // The bar floats at the foot while list rows are ticked (bulk-bar.js, as on
1076
+ // Invoices). Move and Ready are the main steps; Copy and Export take the
1077
+ // ticked rows out; More has them all, a field per custom field, and the
1078
+ // Selection steps.
1079
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
1080
+ const tickedJobs = () => (state.found ?? []).filter((job) => state.ticked.has(jobKey(job)));
1081
+ const setTicks = (jobs) => { state.ticked = new Set(jobs.map(jobKey)); draw(); };
1082
+
1083
+ function drawBulk() {
1084
+ const rows = state.view.layout === 'list' ? tickedJobs() : [];
1085
+ bulk.draw({ count: rows.length, onClear: () => setTicks([]), ...bulkActions(rows) });
1086
+ }
1087
+
1088
+ function bulkActions(rows) {
1089
+ if (!rows.length) return { groups: [], more: [] };
1090
+ const status = [{ icon: 'arrow-right-left', label: 'Move', title: 'Move to a stage', run: () => moveMany(rows) }];
1091
+ if (state.fields.length) status.push({ icon: 'tag', label: 'Custom field', title: 'Set a custom field', run: () => setMany(rows) });
1092
+ const workable = rows.filter((job) => job.obligation?.view === 'in_work');
1093
+ if (workable.length) status.push({ icon: 'check-check', label: 'Ready', title: 'Mark ready to invoice', run: () => readyMany(workable) });
1094
+ const draftable = state.canDraft ? rows.filter((job) => job.obligation?.view === 'ready' && !job.obligation.invoiceId) : [];
1095
+ if (draftable.length) status.push({ icon: 'file-plus', label: 'Draft', title: 'Draft invoices', run: () => draftMany(draftable) });
1096
+ const fields = state.fields.map((field) => ({ icon: 'tag', label: field.name, title: `Set ${field.name}`, run: () => setMany(rows, field.id) }));
1097
+ const apply = [
1098
+ { icon: 'copy', label: 'Copy', iconOnly: true, title: 'Copy case numbers', run: () => copyLines(rows.map((job) => caseNumber(job) ?? clientOf(job))) },
1099
+ { icon: 'download', label: 'Export', iconOnly: true, title: 'Export CSV', run: () => exportCsv(rows) },
1100
+ ];
1101
+ const all = state.found ?? [];
1102
+ const selection = [
1103
+ { icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
1104
+ { icon: 'square-check', label: 'Invert ticks', run: () => setTicks(all.filter((job) => !state.ticked.has(jobKey(job)))) },
1105
+ { icon: 'square-x', label: 'Clear ticks', run: () => setTicks([]) },
1106
+ ];
1107
+ return {
1108
+ groups: [status, apply],
1109
+ more: [
1110
+ { head: 'Change stage', actions: status },
1111
+ { head: 'Set a custom field', actions: fields },
1112
+ { head: 'Apply an action', actions: apply },
1113
+ { head: 'Selection', actions: selection },
1114
+ ],
1115
+ };
1116
+ }
1117
+
1118
+ // Move the ticked jobs to one stage. A work system's own stages
1119
+ // (Seazona) cannot be moved from Flow in bulk yet, so this says so; a
1120
+ // lab's own stages are custom fields (Field). Someone who needs it can
1121
+ // report it (Report a problem).
1122
+ async function moveMany(jobs) {
1123
+ const systems = [...new Set(jobs.map((job) => SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem))].join(' and ');
1124
+ await ui.dialog({
1125
+ title: 'Not yet available',
1126
+ body: h('p', {}, `Moving ${plural(jobs.length, 'job', 'jobs')} to a ${systems} stage at once is not yet available.`),
1127
+ confirm: 'OK',
1128
+ });
1129
+ }
1130
+
1131
+ // One step on each job's invoice record, one job after the other.
1132
+ async function eachJob(jobs, step) {
1133
+ let done = 0;
1134
+ const refused = [];
1135
+ for (const job of jobs) {
1136
+ try { await step(job); done += 1; } catch (e) { refused.push(e); }
1137
+ }
1138
+ return { done, refused };
1139
+ }
1140
+
1141
+ async function readyMany(jobs) {
1142
+ const { done, refused } = await eachJob(jobs, (job) => flow(`/api/invoicing/jobs/${job.obligation.id}/ready`, { method: 'POST', body: JSON.stringify({ ready: true }) }));
1143
+ ui.toast?.(refused.length ? `${done} ready to invoice. ${refused[0].message}` : `${plural(done, 'job', 'jobs')} ready to invoice.`, refused.length ? 'danger' : 'success');
1144
+ state.ticked.clear();
1145
+ await load();
1146
+ }
1147
+
1148
+ async function draftMany(jobs) {
1149
+ const ok = await ui.dialog({
1150
+ title: `Draft ${plural(jobs.length, 'invoice', 'invoices')}?`,
1151
+ body: h('p', {}, 'Flow makes a draft invoice for each job. The drafts stay in Flow until a person checks them.'),
1152
+ confirm: `Draft ${jobs.length}`,
1153
+ });
1154
+ if (!ok) return;
1155
+ const { done, refused } = await eachJob(jobs, (job) => flow(`/api/invoicing/jobs/${job.obligation.id}/draft`, { method: 'POST', body: '{}' }));
1156
+ 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');
1157
+ state.ticked.clear();
1158
+ await load();
1159
+ }
1160
+
1161
+ // Set one custom field on the ticked jobs, in Flow only: choose the
1162
+ // custom field, then its value. `fieldId` starts on that custom field.
1163
+ // A dropdown, person or checkbox has a list of values; a date, number or
1164
+ // text has a box. An empty box clears the value.
1165
+ async function setMany(jobs, fieldId = null) {
1166
+ const fields = state.fields;
1167
+ if (!fields.length) return;
1168
+ const lanesOf = (field) => fieldLanes(field, { people: state.people, jobs: state.jobs, valueOf: (job) => valueOf(job, field.id)?.value ?? null }).filter((lane) => !lane.archived);
1169
+ const listed = (field) => field.groupable;
1170
+ const pickField = h('select', { class: 'fx-select', name: 'field', 'aria-label': 'Custom field' },
1171
+ fields.map((f) => h('option', { value: f.id, selected: f.id === fieldId }, f.name)));
1172
+ const valueHost = h('div', {});
1173
+ const fieldOf = (id) => fields.find((f) => f.id === id) ?? fields[0];
1174
+ const fillValue = () => {
1175
+ const field = fieldOf(pickField.value);
1176
+ const control = listed(field)
1177
+ ? h('select', { class: 'fx-select', name: 'value', 'aria-label': 'Value' }, lanesOf(field).map((lane, i) => h('option', { value: String(i) }, lane.label)))
1178
+ : h('input', {
1179
+ class: 'fx-input', name: 'value', 'aria-label': 'Value',
1180
+ type: field.type === 'date' ? 'date' : field.type === 'number' ? 'number' : 'text', step: field.type === 'number' ? 'any' : null, maxlength: field.type === 'text' ? '500' : null,
1181
+ });
1182
+ valueHost.replaceChildren(h('label', { class: 'fx-field' }, h('span', {}, 'Value'), control));
1183
+ };
1184
+ pickField.addEventListener('change', fillValue);
1185
+ fillValue();
1186
+ const data = await ui.dialog({
1187
+ title: `Set a custom field on ${plural(jobs.length, 'job', 'jobs')}`,
1188
+ body: h('div', { class: 'fx-fields' }, h('label', { class: 'fx-field' }, h('span', {}, 'Custom field'), pickField), valueHost),
1189
+ confirm: 'Set',
1190
+ });
1191
+ if (!data) return;
1192
+ const field = fields.find((f) => f.id === data.get('field'));
1193
+ if (!field) return;
1194
+ let value;
1195
+ let shown;
1196
+ if (listed(field)) {
1197
+ const lane = lanesOf(field)[Number(data.get('value'))];
1198
+ if (!lane) return;
1199
+ ({ value } = lane);
1200
+ shown = lane.label;
1201
+ } else {
1202
+ const raw = String(data.get('value') ?? '').trim();
1203
+ value = raw === '' ? null : field.type === 'number' ? Number(raw) : raw;
1204
+ shown = raw === '' ? 'empty' : field.type === 'date' ? fullDate(raw) : raw;
1205
+ }
1206
+ for (const job of jobs) await setValue(job, field, value);
1207
+ ui.toast?.(`${field.name} set to ${shown} on ${plural(jobs.length, 'job', 'jobs')}.`, 'success');
1208
+ }
1209
+
1210
+ async function copyLines(lines) {
1211
+ try {
1212
+ await navigator.clipboard.writeText(lines.join('\n'));
1213
+ ui.toast?.(`${plural(lines.length, 'line', 'lines')} copied.`, 'success');
1214
+ } catch {
1215
+ ui.toast?.('The browser did not allow a copy.', 'danger');
1216
+ }
1217
+ }
1218
+
1219
+ // The ticked rows as a CSV file, with the columns on the screen.
1220
+ function exportCsv(jobs) {
1221
+ const cell = (v) => (/[",\n]/.test(String(v ?? '')) ? `"${String(v).replace(/"/g, '""')}"` : String(v ?? ''));
1222
+ const lines = [['Case', 'Client', 'Practitioner', 'Stage', 'Due', 'Ordered', 'Total']]
1223
+ .concat(jobs.map((job) => [caseNumber(job) ?? '', clientOf(job), job.practitioner ?? '', statusOf(job), job.dueOn ?? '', job.orderedOn ?? '',
1224
+ job.totalCents == null ? '' : (job.totalCents / 100).toFixed(2)]));
1225
+ const url = URL.createObjectURL(new Blob([lines.map((l) => l.map(cell).join(',')).join('\r\n')], { type: 'text/csv' }));
1226
+ const a = h('a', { href: url, download: `jobs-${today()}.csv` });
1227
+ document.body.append(a);
1228
+ a.click();
1229
+ a.remove();
1230
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1231
+ ui.toast?.(`${plural(jobs.length, 'job', 'jobs')} exported.`, 'success');
593
1232
  }
594
1233
 
595
1234
  // What a person can do with the job's invoice from here.
@@ -667,6 +1306,7 @@ export async function renderJobs(main, ui) {
667
1306
  const o = job.obligation;
668
1307
  const stage = invoiceStage(o);
669
1308
  const found = problemsOf(job);
1309
+ const choose = found.find((f) => f.go === 'choose') ?? null;
670
1310
  const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
671
1311
  const status = job.status ?? NO_STATUS;
672
1312
  const late = isLate(job);
@@ -678,10 +1318,37 @@ export async function renderJobs(main, ui) {
678
1318
  const invoiceText = o?.invoiceId
679
1319
  ? link({ href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } },
680
1320
  `${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' },
1321
+ : choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
1322
+ : stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
1323
+ const rest = found.filter((f) => f.go !== 'choose');
1324
+ const fixBox = rest.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
683
1325
  h('h3', {}, 'Fix'),
684
- found.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, `${f.text} ${f.todo}`), fixLink(f.go)))) : null;
1326
+ rest.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, [f.text, f.todo].filter(Boolean).join(' ')), fixLink(f.go)))) : null;
1327
+ // No invoice, and the match is unclear: one line and two buttons. The
1328
+ // possible matches are compared on the Reconcile page, not here.
1329
+ const chooseBox = choose ? h('section', { class: 'fx-task__choose', 'aria-label': 'Invoice' },
1330
+ svg(WARN_ICON),
1331
+ h('span', {}, choose.text),
1332
+ h('div', { class: 'fx-task__choices' },
1333
+ state.canDraft ? h('button', { class: 'fx-btn fx-btn--primary', type: 'button', onclick: (event) => makeInvoice(event.currentTarget) }, 'Make invoice') : null,
1334
+ h('a', { class: 'fx-btn', href: `/reconcile/${o.id}`, onclick: (event) => { close(); ui.go(event); } }, 'Reconcile'))) : null;
1335
+ // "Make invoice": the person says the job has no invoice yet, then Flow
1336
+ // drafts it (the live check before the draft still runs).
1337
+ async function makeInvoice(button) {
1338
+ if (button) button.disabled = true;
1339
+ try {
1340
+ await flow(`/api/invoicing/jobs/${o.id}/no-invoice`, { method: 'POST', body: '{}' });
1341
+ const { invoice } = await flow(`/api/invoicing/jobs/${o.id}/draft`, { method: 'POST', body: '{}' });
1342
+ ui.toast('Draft invoice made.', 'success');
1343
+ close();
1344
+ ui.go(`/invoices/${invoice.id}`);
1345
+ } catch (error) {
1346
+ const reasons = error.details?.reasons;
1347
+ ui.toast(reasons?.length ? `Not drafted: ${reasons.join('; ')}.` : error.message, 'danger');
1348
+ close();
1349
+ await load();
1350
+ }
1351
+ }
685
1352
  // Each fix's link: Flow's own invoice, the job in the work system, or
686
1353
  // the Flow page where the fix is made (job-board.js GO).
687
1354
  function fixLink(go) {
@@ -718,9 +1385,10 @@ export async function renderJobs(main, ui) {
718
1385
  // The job links to Flow's invoice only. The invoice page shows its
719
1386
  // link to an accounting system (its Linked card).
720
1387
  field('Invoice', invoiceText)),
1388
+ chooseBox,
721
1389
  fixBox,
722
1390
  state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
723
- h('h3', {}, 'Fields'),
1391
+ h('h3', {}, 'Custom fields'),
724
1392
  h('dl', { class: 'fx-task__fields' }, state.fields.map((f) => h('div', {}, h('dt', {}, f.name), h('dd', {}, fieldControl(job, f)))))) : null,
725
1393
  movesStages(job) ? h('section', { class: 'fx-task__custom', 'aria-label': `${system} stage` },
726
1394
  h('h3', {}, `${system} stage`), stageControl(job)) : null,
@@ -745,7 +1413,7 @@ export async function renderJobs(main, ui) {
745
1413
  element.addEventListener('close', () => element.remove());
746
1414
  document.body.append(element);
747
1415
  element.showModal();
748
- if (atFix && fixBox) fixBox.scrollIntoView({ block: 'start' });
1416
+ if (atFix && (chooseBox ?? fixBox)) (chooseBox ?? fixBox).scrollIntoView({ block: 'start' });
749
1417
  if (!o) return;
750
1418
  const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
751
1419
  flow(`/api/invoicing/jobs/${o.id}/activity`).then(({ events }) => {