@arcforgelabs/flow 2026.10.37 → 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.
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
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) {
@@ -81,16 +115,21 @@ export async function renderJobs(main, ui) {
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),
@@ -100,9 +139,23 @@ export async function renderJobs(main, ui) {
100
139
  search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
101
140
 
102
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() {
@@ -308,7 +391,8 @@ export async function renderJobs(main, ui) {
308
391
  return { ...g, rank: 'plain', color: lane?.color, drop: lane && !lane.archived ? { kind: 'field', field, value: lane.value } : null };
309
392
  });
310
393
  }
311
- 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]) {
312
396
  const hidden = new Set(state.view.hide);
313
397
  return groupJobs(jobs, orderStatuses(state.statuses, state.jobs).filter((label) => !hidden.has(label)), laneStatusOf)
314
398
  .map((g) => ({ ...g, rank: statusRank(g.label), drop: g.label === NO_STATUS ? null : { kind: 'stage', status: g.label } }));
@@ -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,37 @@ 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}`), fixLink(f.go)))) : 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
+ }
685
1101
  // Each fix's link: Flow's own invoice, the job in the work system, or
686
1102
  // the Flow page where the fix is made (job-board.js GO).
687
1103
  function fixLink(go) {
@@ -718,9 +1134,10 @@ export async function renderJobs(main, ui) {
718
1134
  // The job links to Flow's invoice only. The invoice page shows its
719
1135
  // link to an accounting system (its Linked card).
720
1136
  field('Invoice', invoiceText)),
1137
+ chooseBox,
721
1138
  fixBox,
722
1139
  state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
723
- h('h3', {}, 'Fields'),
1140
+ h('h3', {}, 'Custom fields'),
724
1141
  h('dl', { class: 'fx-task__fields' }, state.fields.map((f) => h('div', {}, h('dt', {}, f.name), h('dd', {}, fieldControl(job, f)))))) : null,
725
1142
  movesStages(job) ? h('section', { class: 'fx-task__custom', 'aria-label': `${system} stage` },
726
1143
  h('h3', {}, `${system} stage`), stageControl(job)) : null,
@@ -745,7 +1162,7 @@ export async function renderJobs(main, ui) {
745
1162
  element.addEventListener('close', () => element.remove());
746
1163
  document.body.append(element);
747
1164
  element.showModal();
748
- if (atFix && fixBox) fixBox.scrollIntoView({ block: 'start' });
1165
+ if (atFix && (chooseBox ?? fixBox)) (chooseBox ?? fixBox).scrollIntoView({ block: 'start' });
749
1166
  if (!o) return;
750
1167
  const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
751
1168
  flow(`/api/invoicing/jobs/${o.id}/activity`).then(({ events }) => {