@arcforgelabs/flow 2026.10.42 → 2026.10.44

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 (40) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +4 -3
  3. package/package.json +5 -3
  4. package/server/auth/people.js +40 -2
  5. package/server/auth/routes.js +11 -0
  6. package/server/auth/session.js +22 -3
  7. package/server/core/custom-fields.js +4 -3
  8. package/server/core/invoice-rules.js +8 -3
  9. package/server/core/job-match.js +6 -2
  10. package/server/core/sending.js +4 -3
  11. package/server/integrations/sentry/index.js +9 -1
  12. package/server/release-channel.js +5 -2
  13. package/server/ui/app-page.js +1 -1
  14. package/server/ui/app.js +46 -162
  15. package/server/ui/automations.js +9 -3
  16. package/server/ui/columns.js +268 -0
  17. package/server/ui/custom-fields.js +17 -16
  18. package/server/ui/flow.css +268 -55
  19. package/server/ui/invoice-notes.js +7 -5
  20. package/server/ui/invoice-rules.js +3 -3
  21. package/server/ui/invoicing-settings.js +92 -35
  22. package/server/ui/invoicing.js +206 -119
  23. package/server/ui/job-board.js +20 -8
  24. package/server/ui/job-change.js +36 -0
  25. package/server/ui/job-view.js +255 -60
  26. package/server/ui/jobs.js +197 -145
  27. package/server/ui/kit.js +278 -0
  28. package/server/ui/list-views.js +73 -0
  29. package/server/ui/monitoring.js +1 -0
  30. package/server/ui/person.js +29 -0
  31. package/server/ui/price-list.js +2 -3
  32. package/server/ui/price-reconcile.js +7 -8
  33. package/server/ui/reconcile.js +114 -61
  34. package/server/ui/routes.js +1 -1
  35. package/server/ui/saved-views.js +141 -0
  36. package/server/ui/search.js +8 -0
  37. package/server/ui/sidecar.js +72 -0
  38. package/server/ui/tip.js +99 -0
  39. package/server/ui/view-bar.js +176 -70
  40. package/server/ui/xero-import.js +7 -10
package/server/ui/jobs.js CHANGED
@@ -13,8 +13,10 @@
13
13
  // or with a keyboard, the job's panel has the same moves.
14
14
 
15
15
  import { bulkBar } from '/assets/ui/bulk-bar.js';
16
+ import { columnTable, onRightButton, rowCells, viewSaver } from '/assets/ui/columns.js';
16
17
  import { lucide } from '/assets/ui/custom-fields.js';
17
18
  import { formatCents } from '/assets/ui/invoicing.js';
19
+ import { jobChangeReview } from '/assets/ui/job-change.js';
18
20
  import {
19
21
  caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
20
22
  } from '/assets/ui/job-board.js';
@@ -22,10 +24,16 @@ import {
22
24
  COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
23
25
  normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
24
26
  } from '/assets/ui/job-view.js';
27
+ import { clipLabel, initialsOf } from '/assets/ui/person.js';
28
+ import { savedViews } from '/assets/ui/saved-views.js';
29
+ import { sidecar } from '/assets/ui/sidecar.js';
25
30
  import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
26
31
 
27
32
  const LAYOUT_KEY = 'flow_jobs_layout';
28
33
  const FOLDED_KEY = 'flow_jobs_folded';
34
+ // The job panel's sidecar: the tab last open, or closed.
35
+ const SIDE_KEY = 'flow_job_side';
36
+ const SIDE_TABS = ['activity', 'fix', 'closed'];
29
37
  const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
30
38
  // A job's events (server/core/ledger.js), in plain words.
31
39
  const ACTIVITY = {
@@ -56,11 +64,6 @@ const shortDate = (iso) => {
56
64
  if (days === -1) return 'Yesterday';
57
65
  return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
58
66
  };
59
- // A person's badge: their initials in a circle; the name on hover.
60
- const initialsOf = (name) => {
61
- const parts = (name ?? '?').split(/[\s._-]+/).filter(Boolean);
62
- return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
63
- };
64
67
  const clientOf = (job) => job.clientName ?? 'No client';
65
68
  // The one date a card shows: when the job is due, else when it was ordered.
66
69
  const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
@@ -85,6 +88,17 @@ function saveFolded(folded) {
85
88
  try { localStorage.setItem(FOLDED_KEY, JSON.stringify(folded)); } catch { /* a convenience only */ }
86
89
  }
87
90
 
91
+ function readSide() {
92
+ try {
93
+ const value = localStorage.getItem(SIDE_KEY);
94
+ return SIDE_TABS.includes(value) ? value : 'activity';
95
+ } catch { return 'activity'; }
96
+ }
97
+
98
+ function saveSide(tab) {
99
+ try { localStorage.setItem(SIDE_KEY, tab ?? 'closed'); } catch { /* a convenience only */ }
100
+ }
101
+
88
102
 
89
103
  const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6v3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r=".9" fill="currentColor"/></svg>';
90
104
  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>';
@@ -92,7 +106,7 @@ const CHEVRON_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidde
92
106
  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>';
93
107
 
94
108
  export async function renderJobs(main, ui) {
95
- const { h, flow, pageHeader, notice } = ui;
109
+ const { h, flow, listHead, listFrame, edgeFade, notice } = ui;
96
110
  // Search (search.js) opens a job with no invoice as /jobs?q=<case>.
97
111
  const asked = new URLSearchParams(location.search).get('q') ?? '';
98
112
  // The person's own view (job-view.js), saved with their account. Before
@@ -110,24 +124,26 @@ export async function renderJobs(main, ui) {
110
124
  // The list's ticked jobs (by jobKey), and its folded groups by group key.
111
125
  ticked: new Set(), folded: readFolded(),
112
126
  };
127
+ // The person's named views (saved-views.js): their default opens on a
128
+ // new visit.
129
+ const views = savedViews(ui, { page: 'jobs', current: () => state.view, apply: (view) => { change(view); drawToggle(); } });
130
+ const opening = views.opening();
131
+ if (opening) state.view = normaliseJobsView(opening);
113
132
  const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
114
133
  const find = findBox(h, { label: 'Find a case, client or practitioner', value: asked, oninput: (words) => { state.q = words.trim(); draw(); } });
115
134
  const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
116
135
  const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
117
136
  // In the list, ticked rows get the same floating bar as Invoices.
118
137
  const bulk = bulkBar(ui, { label: 'Ticked jobs' });
119
- main.classList.add('fx-main--wide');
120
- main.append(
121
- pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
122
- h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, find.element, toggle)),
123
- host,
124
- bulk.element);
138
+ const frame = listFrame(main, host);
139
+ main.append(...listHead('Jobs', { views: views.element, start: controls, end: [find.element, toggle] }).nodes, frame.element, bulk.element);
125
140
 
126
141
  const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
127
142
  type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
128
143
  onclick: () => { saveLayout(id); change({ layout: id }); drawToggle(); },
129
144
  }, label)));
130
145
  drawToggle();
146
+ views.draw();
131
147
 
132
148
  const problemsOf = (job) => fixes(job.obligation);
133
149
  const jobKey = (job) => `${job.sourceSystem}\u0000${job.sourceJobRef}`;
@@ -154,17 +170,13 @@ export async function renderJobs(main, ui) {
154
170
 
155
171
  // A change to the view: draw it now, save it with the account shortly
156
172
  // after, so a few quick clicks make one request.
157
- let saveTimer = null;
173
+ const saveView = viewSaver(ui, 'jobs');
158
174
  function change(patch) {
159
175
  state.view = normaliseJobsView({ ...state.view, ...patch });
160
- if (ui.me) ui.me.views = { ...ui.me.views, jobs: state.view };
176
+ views.draw();
161
177
  drawControls();
162
178
  draw();
163
- clearTimeout(saveTimer);
164
- saveTimer = setTimeout(() => {
165
- flow('/api/me/views/jobs', { method: 'PUT', body: JSON.stringify(state.view) })
166
- .catch(() => ui.toast?.('Flow could not save your view. It applies on this page only.', 'danger'));
167
- }, 500);
179
+ saveView(state.view);
168
180
  }
169
181
 
170
182
  // The chips (view-bar.js), shared with every list page.
@@ -226,11 +238,11 @@ export async function renderJobs(main, ui) {
226
238
  }, { rows: true, clearLabel: 'Clear the sort' });
227
239
 
228
240
  // ── Filters ──
229
- // Rows of field, operator and value, with a bin on each, "Add filter",
230
- // and "Clear all". The second row's And / Or applies to every row.
241
+ // The shared filter panel (view-bar.js), as ClickUp's: groups of rows of
242
+ // field, operator and value. A job's fields, then the lab's custom fields.
231
243
  const filterFields = () => [
232
244
  ...Object.entries(FILTER_FIELDS).filter(([id]) => id !== 'assignee' || assigneeField())
233
- .map(([value, f]) => ({ value, label: value === 'assignee' ? assigneeField().name : f.label })),
245
+ .map(([value, f]) => ({ value, label: value === 'assignee' ? assigneeField().name : f.label, icon: f.icon })),
234
246
  { group: 'Custom fields', options: customColumns().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
235
247
  ];
236
248
  const kindOf = (field) => {
@@ -238,20 +250,23 @@ export async function renderJobs(main, ui) {
238
250
  if (Object.hasOwn(FILTER_FIELDS, field)) return FILTER_FIELDS[field].kind;
239
251
  return KIND_OF_TYPE[fieldById(fieldIdOf(field))?.type] ?? null;
240
252
  };
241
- const PROBLEM_OPS = { yes: 'has one', no: 'has none' };
242
- const opsOf = (field) => (field === 'problem' ? PROBLEM_OPS : OPERATORS[kindOf(field)] ?? {});
253
+ // Status and stage are always set, so they offer Is and Is not, as
254
+ // ClickUp's Status does.
255
+ const PROBLEM_OPS = { yes: 'Has one', no: 'Has none' };
256
+ const SET_OPS = { is: 'Is', not: 'Is not' };
257
+ const opsOf = (field) => (field === 'problem' ? PROBLEM_OPS : ['status', 'stage'].includes(field) ? SET_OPS : OPERATORS[kindOf(field)] ?? {});
243
258
  // The choices a choice or person filter offers: [{ value, label }].
244
259
  function choicesOf(field) {
245
260
  const people = () => [{ value: 'me', label: 'Me' }, ...state.people.map((p) => ({ value: p.id, label: p.name }))];
246
261
  const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
247
- if (field === 'status') return orderStatuses(state.statuses, state.jobs).map((v) => ({ value: v, label: v }));
262
+ if (field === 'status') return orderStatuses(state.statuses, state.jobs).map((v) => ({ value: v, label: v, rank: statusRank(v) }));
248
263
  if (field === 'stage') return Object.values(STAGE_GROUPS).map((v) => ({ value: v, label: v }));
249
264
  if (field === 'client') return labels(state.jobs.map(clientOf));
250
265
  if (field === 'practitioner') return labels(state.jobs.map((job) => job.practitioner));
251
266
  if (field === 'assignee') return people();
252
267
  const custom = fieldById(fieldIdOf(field));
253
268
  if (custom?.type === 'person') return people();
254
- return (custom?.options ?? []).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name }));
269
+ return (custom?.options ?? []).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name, color: o.color }));
255
270
  }
256
271
  // A job's value for a filter field, as the filter compares it.
257
272
  function filterValue(job, field) {
@@ -335,6 +350,7 @@ export async function renderJobs(main, ui) {
335
350
  const shown = new Set(found.map(jobKey));
336
351
  for (const key of state.ticked) if (!shown.has(key)) state.ticked.delete(key);
337
352
  state.found = found;
353
+ frame.count(`${found.length} ${found.length === 1 ? 'job' : 'jobs'}`);
338
354
  host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
339
355
  drawBulk();
340
356
  }
@@ -453,6 +469,9 @@ export async function renderJobs(main, ui) {
453
469
  h('span', { class: 'fx-board__sum' }, jobs.length ? formatCents(totalOf(jobs)) : '')),
454
470
  h('div', { class: 'fx-board__cards' }, jobs.length ? jobs.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')))));
455
471
  if (groups.some((g) => g.drop)) enableDrag(element, groups);
472
+ // Lanes past the frame's sides, and cards past a lane's foot, fade.
473
+ edgeFade(element, { target: frame.view, edges: ['left', 'right'] });
474
+ element.querySelectorAll('.fx-board__cards').forEach((cards) => edgeFade(cards, { target: cards.parentElement, edges: ['top', 'bottom'] }));
456
475
  return element;
457
476
  }
458
477
 
@@ -649,10 +668,10 @@ export async function renderJobs(main, ui) {
649
668
  const label = field.name;
650
669
  if (field.type === 'dropdown' || field.type === 'person') {
651
670
  const choices = field.type === 'person'
652
- ? state.people.map((p) => ({ value: p.id, label: p.name }))
671
+ ? state.people.map((p) => ({ value: p.id, label: p.name, title: p.name }))
653
672
  : field.options.filter((o) => !o.archived || o.id === current).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name }));
654
673
  const select = h('select', { class: 'fx-select fx-task__input', 'aria-label': label },
655
- h('option', { value: '' }, NOT_SET), choices.map((c) => h('option', { value: c.value, selected: c.value === current }, c.label)));
674
+ h('option', { value: '' }, NOT_SET), choices.map((c) => h('option', { value: c.value, selected: c.value === current, title: c.title ?? null }, clipLabel(c.label))));
656
675
  select.addEventListener('change', () => save(select.value || null));
657
676
  return select;
658
677
  }
@@ -690,9 +709,6 @@ export async function renderJobs(main, ui) {
690
709
  return h('div', { class: 'fx-task__move' }, wait, select);
691
710
  }
692
711
 
693
- // A column's menu (a click on its head, or a right-click): sort by it,
694
- // move it, hide it. Client always shows. `column` null is the + menu: the
695
- // hidden columns, to add one back.
696
712
  // What a column's head sorts by: a built-in key, or the column's custom
697
713
  // field (Assignee is the lab's person field).
698
714
  const SORT_OF = { client: 'client', practitioner: 'practitioner', due: 'due', ordered: 'ordered', shipped: 'shipped', total: 'total' };
@@ -742,65 +758,43 @@ export async function renderJobs(main, ui) {
742
758
  on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
743
759
  }
744
760
  function sortMenuAt(event, by, label, on, at) {
745
- const close = () => columnPop.hidePopover?.();
746
761
  const { sorts } = state.view;
747
762
  const flip = () => change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)) });
748
- columnPop.replaceChildren(
749
- h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { close(); flip(); } },
763
+ heads.menu.show([
764
+ h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { heads.menu.close(); flip(); } },
750
765
  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(ICONS.tick))),
751
- h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ sorts: sorts.filter((_, n) => n !== at) }); } },
752
- h('span', { class: 'fx-pop__tick' }, lucide(h, 'ban')), h('span', {}, 'Clear sort')));
753
- placeColumnPop(event);
754
- }
755
- const columnPop = h('div', { class: 'fx-pop', id: 'fx-jobs-colmenu', popover: 'auto', role: 'menu' });
756
- document.body.append(columnPop);
757
- // A popover opened while the right button is still down closes again on
758
- // its release (light dismiss), so open it after the release.
759
- function onRightButton(event, open) {
760
- if (event.buttons & 2) addEventListener('pointerup', () => setTimeout(open), { once: true });
761
- else open();
766
+ heads.menu.item('ban', 'Clear sort', () => change({ sorts: sorts.filter((_, n) => n !== at) })),
767
+ ], event);
762
768
  }
763
- function columnMenu(column, event) {
764
- const ids = state.view.columns;
765
- const close = () => columnPop.hidePopover?.();
766
- const item = (icon, text, run) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); run(); } },
767
- h('span', { class: 'fx-pop__tick' }, lucide(h, icon)), h('span', {}, text));
768
- const parts = [];
769
- if (column) {
769
+ // The list's heads (columns.js): drag an edge to resize, drag a head to
770
+ // move it; a click or right-click opens its menu: sort by it, move it,
771
+ // edit its custom field, hide it. Client always shows, first. + lists the
772
+ // hidden columns, to add one back.
773
+ const heads = columnTable(h, {
774
+ order: () => state.view.columns,
775
+ widths: () => state.view.widths,
776
+ change,
777
+ hidden: () => {
778
+ const shown = new Set(state.view.columns);
779
+ return [
780
+ ...builtInColumns().filter((id) => !shown.has(id)).map((id) => ({ id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id] })),
781
+ ...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
782
+ ];
783
+ },
784
+ extra: (column, item) => {
785
+ const parts = [];
770
786
  const sort = sortOf(column.id);
771
787
  if (sort) {
772
788
  const [up, down] = dirWords(sort);
773
789
  parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sorts: [{ by: sort, dir: 'asc' }] })),
774
790
  item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sorts: [{ by: sort, dir: 'desc' }] })), h('div', { class: 'fx-pop__sep' }));
775
791
  }
776
- const rest = ids.filter((c) => c !== column.id);
777
- parts.push(item('arrow-left-to-line', 'Move to start', () => change({ columns: [column.id, ...rest] })),
778
- item('arrow-right-to-line', 'Move to end', () => change({ columns: [...rest, column.id] })));
779
792
  if (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)) {
780
793
  parts.push(item('settings', 'Edit custom field', () => ui.go('/custom-fields')));
781
794
  }
782
- parts.push(h('div', { class: 'fx-pop__sep' }), item('eye-off', 'Hide column', () => change({ columns: rest })));
783
- }
784
- // + lists the hidden columns: a click adds one at the end.
785
- if (!column) {
786
- const shown = new Set(ids);
787
- const hidden = [
788
- ...builtInColumns().filter((id) => !shown.has(id)).map((id) => ({ id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id] })),
789
- ...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
790
- ];
791
- parts.push(...hidden.map((c) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ columns: [...ids, c.id] }); } }, c.label)));
792
- if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
793
- }
794
- columnPop.replaceChildren(...parts);
795
- placeColumnPop(event);
796
- }
797
- function placeColumnPop(event) {
798
- columnPop.showPopover?.();
799
- const x = event.clientX || event.currentTarget?.getBoundingClientRect?.().left || 0;
800
- const y = event.clientY || event.currentTarget?.getBoundingClientRect?.().bottom || 0;
801
- columnPop.style.top = `${Math.round(Math.min(y + 4, innerHeight - columnPop.offsetHeight - 8))}px`;
802
- columnPop.style.left = `${Math.round(Math.max(8, Math.min(x, innerWidth - columnPop.offsetWidth - 8)))}px`;
803
- }
795
+ return parts;
796
+ },
797
+ });
804
798
 
805
799
  // The list's columns, in the order the person chose them. One column holds
806
800
  // one field; each date has its own column.
@@ -808,25 +802,30 @@ export async function renderJobs(main, ui) {
808
802
  const day = (iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
809
803
  const late = (job) => (isLate(job) ? h('span', { class: 'fx-job__date--late', title: 'Past due' }, day(job.dueOn)) : day(job.dueOn));
810
804
  const BUILT_IN = {
811
- case: { kind: 'case', dim: true, cell: (job) => caseNumber(job) ?? '—' },
812
- practitioner: { kind: 'text', dim: true, cell: (job) => job.practitioner ?? '—' },
813
- patient: { kind: 'text', dim: true, cell: (job) => job.patient ?? '—' },
805
+ case: { kind: 'case', className: 'fx-dim', cell: (job) => caseNumber(job) ?? '—' },
806
+ practitioner: { kind: 'text', className: 'fx-dim', cell: (job) => job.practitioner ?? '—' },
807
+ patient: { kind: 'text', className: 'fx-dim', cell: (job) => job.patient ?? '—' },
814
808
  stage: { kind: 'stage', cell: (job) => stagePill(statusOf(job)) },
815
809
  assignee: { kind: 'person', cell: (job) => personBadge(job, assigneeField()) },
816
- ordered: { kind: 'date', dim: true, cell: (job) => day(job.orderedOn) },
817
- due: { kind: 'date', dim: true, cell: late },
818
- shipped: { kind: 'date', dim: true, cell: (job) => day(job.shippedOn) },
819
- total: { kind: 'total', num: true, strong: true, cell: (job) => (job.totalCents == null ? '—' : formatCents(job.totalCents)) },
810
+ ordered: { kind: 'date', className: 'fx-dim', cell: (job) => day(job.orderedOn) },
811
+ due: { kind: 'date', className: 'fx-dim', cell: late },
812
+ shipped: { kind: 'date', className: 'fx-dim', cell: (job) => day(job.shippedOn) },
813
+ total: { kind: 'total', num: true, className: 'fx-strong', cell: (job) => (job.totalCents == null ? '—' : formatCents(job.totalCents)) },
820
814
  };
821
815
  const allowed = new Set(builtInColumns());
822
- return state.view.columns.map((id) => {
816
+ // Client is always the first column: it has no drag, Move or Hide.
817
+ const client = {
818
+ id: 'client', label: 'Client', kind: 'client', fixed: true, sort: () => sortButton('client', 'Client'),
819
+ cell: (job) => h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job)),
820
+ };
821
+ return [client, ...state.view.columns.map((id) => {
823
822
  if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
824
823
  const field = customColumns().find((f) => fieldGroup(f.id) === id);
825
824
  if (!field) return null;
826
825
  const cell = field.type === 'person' ? (job) => personBadge(job, field)
827
826
  : field.type === 'date' ? (job) => day(valueOf(job, field.id)?.value ?? null) : (job) => fieldText(job, field);
828
- 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 };
829
- }).filter(Boolean);
827
+ return { id, label: field.name, kind: field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text', num: field.type === 'number', className: 'fx-dim', cell };
828
+ }).filter(Boolean).map((c) => ({ ...c, sort: () => sortButton(c.id, c.label) }))];
830
829
  }
831
830
 
832
831
  // A person custom field's value as a badge (initials), the name on hover.
@@ -883,8 +882,7 @@ export async function renderJobs(main, ui) {
883
882
  onkeydown: (event) => { if (event.key === 'Enter' && event.target === event.currentTarget) openJob(job); },
884
883
  },
885
884
  tickCell(setBox([job], `Tick ${clientOf(job)}${caseNumber(job) ? `, case ${caseNumber(job)}` : ''}`)),
886
- h('td', { class: 'fx-c-client' }, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
887
- 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))),
885
+ rowCells(h, columns, job),
888
886
  h('td', { class: 'fx-c-end' }));
889
887
  const fold = (label) => {
890
888
  const next = new Set(state.folded[state.view.group] ?? []);
@@ -902,22 +900,16 @@ export async function renderJobs(main, ui) {
902
900
  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));
903
901
  return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: () => fold(label) },
904
902
  tickCell(setBox(jobs, `Tick every job in ${label}`)),
905
- h('td', { colspan: String(columns.length + 2) }, h('span', { class: 'fx-table__group-row' }, toggle, h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))))));
903
+ h('td', { colspan: String(columns.length + 1) }, h('span', { class: 'fx-table__group-row' }, toggle, h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))))));
906
904
  };
907
- return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
908
- h('thead', {}, h('tr', {},
909
- h('th', { class: 'fx-tick-cell' }, setBox(allJobs, 'Tick every job shown')),
910
- h('th', {}, h('span', { class: 'fx-th-cell' }, h('span', { class: 'fx-th fx-th--fixed' }, 'Client'), sortButton('client', 'Client'))),
911
- columns.map((c) => h('th', { class: c.num ? 'fx-num' : null, oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => columnMenu(c, event)); } },
912
- h('span', { class: 'fx-th-cell' },
913
- h('button', { type: 'button', class: 'fx-th', 'aria-haspopup': 'menu', title: 'Column menu (or right-click)', onclick: (event) => columnMenu(c, event) }, c.label),
914
- sortButton(c.id, c.label)))),
915
- h('th', { class: 'fx-th-add' }, h('button', {
916
- type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => columnMenu(null, event),
917
- }, '+')))),
918
- shownGroups.map((g) => h('tbody', {},
905
+ return h('div', { class: 'fx-panel' }, heads.table({
906
+ className: 'fx-table--jobs',
907
+ columns,
908
+ lead: [h('th', { class: 'fx-tick-cell' }, setBox(allJobs, 'Tick every job shown'))],
909
+ rows: shownGroups.map((g) => h('tbody', {},
919
910
  g.label == null ? null : head(g),
920
- g.label != null && folded.has(g.label) ? null : g.jobs.map(row)))));
911
+ g.label != null && folded.has(g.label) ? null : g.jobs.map(row))),
912
+ }));
921
913
  }
922
914
 
923
915
  // ── Bulk actions ─────────────────────────────────────────────────────────
@@ -1086,25 +1078,8 @@ export async function renderJobs(main, ui) {
1086
1078
  if (!o) return [];
1087
1079
  if (o.invoiceId) {
1088
1080
  const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
1089
- const open = h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label);
1090
- // Only the job changed and the draft is not sent (ledger.js): one tap
1091
- // puts the job's new lines on Flow's own draft. Nothing goes elsewhere.
1092
- if (!o.canUseNewLines) return [open];
1093
- const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
1094
- return [h('button', {
1095
- class: 'fx-btn fx-btn--primary', type: 'button',
1096
- onclick: async (event) => {
1097
- event.currentTarget.disabled = true;
1098
- try {
1099
- await flow(`/api/invoicing/jobs/${o.id}/use-new-lines`, { method: 'POST', body: '{}' });
1100
- ui.toast(`The draft invoice now matches ${system}.`, 'success');
1101
- } catch (error) {
1102
- ui.toast(error.message, 'danger');
1103
- }
1104
- close();
1105
- await load();
1106
- },
1107
- }, `Adopt ${system} changes`), open];
1081
+ // A changed job is accepted in the panel's sidecar (Fix tab).
1082
+ return [h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label)];
1108
1083
  }
1109
1084
  // Flow's own stage: a person marks a job ready to invoice, or an
1110
1085
  // automation does (Settings, Automations).
@@ -1147,15 +1122,20 @@ export async function renderJobs(main, ui) {
1147
1122
 
1148
1123
  // The job's page: a large panel over the board, like a task page. A
1149
1124
  // breadcrumb, the client's name, the stage, the job's fields, what to fix
1150
- // and its lines; its activity at the side (below on a phone). Esc, the X
1151
- // or a click outside closes it. `atFix` scrolls to what to fix. The job's
1152
- // custom fields can be changed here (in Flow only), and its stage moved
1153
- // (a hand-off to the work system).
1125
+ // and its lines. At the side (below on a phone), the sidecar: Activity,
1126
+ // and Fix when the job has a problem (for a changed job: what changed,
1127
+ // and Accept change). The sidecar closes, and the main area takes its room.
1128
+ // Esc, the X or a click outside closes the panel. `atFix` opens the
1129
+ // Fix tab on the first problem (or scrolls to the invoice choice). The job's custom fields can
1130
+ // be changed here (in Flow only), and its stage moved (a hand-off to the
1131
+ // work system).
1154
1132
  function openJob(job, { atFix = false } = {}) {
1155
1133
  const o = job.obligation;
1156
1134
  const stage = invoiceStage(o);
1157
1135
  const found = problemsOf(job);
1158
1136
  const choose = found.find((f) => f.go === 'choose') ?? null;
1137
+ // The job changed after its draft: its Fix shows the change.
1138
+ const reviewable = Boolean(o?.invoiceId) && found.some((f) => f.go === 'review');
1159
1139
  const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
1160
1140
  const status = job.status ?? NO_STATUS;
1161
1141
  const late = isLate(job);
@@ -1170,9 +1150,24 @@ export async function renderJobs(main, ui) {
1170
1150
  : choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
1171
1151
  : stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
1172
1152
  const rest = found.filter((f) => f.go !== 'choose');
1173
- const fixBox = rest.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
1174
- h('h3', {}, 'Fix'),
1175
- rest.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, [f.text, f.todo].filter(Boolean).join(' ')), fixLink(f.go)))) : null;
1153
+ // What to fix: one chip a problem, beside the stage, so the panel keeps
1154
+ // one layout with or without a fix. A chip is a toggle: a click opens
1155
+ // the sidecar's Fix tab on that problem and shows the chip pressed; a
1156
+ // second click closes the sidecar. In the tab the problem is fixed: the
1157
+ // change and Accept change for a changed job, else what is wrong, what
1158
+ // to do and one button.
1159
+ const hasFix = rest.length > 0;
1160
+ let picked = null;
1161
+ const fixChips = rest.map((f, i) => h('button', {
1162
+ class: 'fx-task__fixchip', type: 'button', 'aria-pressed': 'false', onclick: () => toggleFix(i),
1163
+ }, lucide(h, 'triangle-alert'), f.label));
1164
+ function toggleFix(i) {
1165
+ if (picked === i && side.tab() === 'fix') { side.close(); return; }
1166
+ picked = i;
1167
+ side.open('fix');
1168
+ // On a phone the sidecar is below the job: bring it into view.
1169
+ if (matchMedia('(max-width: 720px)').matches) side.element.scrollIntoView({ block: 'start' });
1170
+ }
1176
1171
  // No invoice, and the match is unclear: one line and two buttons. The
1177
1172
  // possible matches are compared on the Reconcile page, not here.
1178
1173
  const chooseBox = choose ? h('section', { class: 'fx-task__choose', 'aria-label': 'Invoice' },
@@ -1200,27 +1195,84 @@ export async function renderJobs(main, ui) {
1200
1195
  }
1201
1196
  // Each fix's link: Flow's own invoice, the job in the work system, or
1202
1197
  // the Flow page where the fix is made (job-board.js GO).
1203
- function fixLink(go) {
1204
- const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, onclick: (event) => { close(); ui.go(event); } }, text);
1205
- if (go === 'invoice' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
1206
- if (go === 'work' && job.link?.url) {
1207
- return h('a', { class: 'fx-task__link', href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`);
1208
- }
1209
- if (go === 'price-list') return inFlow('/price-list', 'Open price list');
1210
- if (go === 'invoicing-settings') return inFlow('/invoicing-settings', 'Open invoice rules');
1211
- if (go === 'integrations' && ui.me?.person?.role === 'owner') return inFlow('/integrations', 'Open integrations');
1198
+ function fixLink(go, tip = null) {
1199
+ const button = (icon, text, attrs) => h(attrs.href ? 'a' : 'button', {
1200
+ class: 'fx-btn fx-btn--sm fx-btn--primary fx-task__fixgo', title: tip, ...(attrs.href ? {} : { type: 'button' }), ...attrs,
1201
+ }, lucide(h, icon), text);
1202
+ const inFlow = (icon, href, text) => button(icon, text, { href, onclick: (event) => { close(); ui.go(event); } });
1203
+ if ((go === 'review' || go === 'invoice') && o?.invoiceId) return inFlow('file-text', `/invoices/${o.invoiceId}`, 'Invoice');
1204
+ if (go === 'work' && job.link?.url) return button('external-link', system, { href: job.link.url, target: '_blank', rel: 'noopener' });
1205
+ if (go === 'price-list') return inFlow('tag', '/price-list', 'Price list');
1206
+ if (go === 'invoicing-settings') return inFlow('list-checks', '/invoicing-settings', 'Rules');
1207
+ if (go === 'integrations' && ui.me?.person?.role === 'owner') return inFlow('plug', '/integrations', 'Integrations');
1212
1208
  return null;
1213
1209
  }
1214
1210
  const lines = job.lines ?? [];
1215
1211
  const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
1212
+ const activityBody = h('div', { class: 'fx-task__activity' },
1213
+ o ? activity : h('p', { class: 'fx-dim' }, `Flow has no invoice record for this job yet. It makes one when ${system} reports the job complete.`));
1214
+ // The change: as the invoice page shows it (job-change.js),
1215
+ // read from the invoice when the sidecar first needs it.
1216
+ const reviewBody = h('div', { class: 'fx-task__review' }, h('p', { class: 'fx-dim' }, 'Loading…'));
1217
+ let reviewRead = false;
1218
+ const readReview = () => {
1219
+ if (reviewRead) return;
1220
+ reviewRead = true;
1221
+ flow(`/api/invoicing/invoices/${o.invoiceId}`).then(({ invoice }) => drawReview(invoice))
1222
+ .catch(() => reviewBody.replaceChildren(h('p', {}, 'Flow could not read the change.')));
1223
+ };
1224
+ function drawReview(invoice) {
1225
+ const change = invoice?.jobChange ?? null;
1226
+ reviewBody.replaceChildren(change
1227
+ ? jobChangeReview(ui, change, { money: formatCents, canAccept: state.canDraft, done: reopen })
1228
+ : h('p', { class: 'fx-dim' }, `The draft matches ${system}.`));
1229
+ }
1230
+ // After Accept change or a recheck: the board reads again, and this
1231
+ // job's panel opens again with its new facts.
1232
+ async function reopen() {
1233
+ await load();
1234
+ const fresh = state.jobs.find((j) => jobKey(j) === jobKey(job));
1235
+ close();
1236
+ if (fresh) openJob(fresh);
1237
+ }
1238
+ // The Fix tab: the picked problem (the first when the tab itself is
1239
+ // clicked).
1240
+ function fixBody() {
1241
+ const f = rest[picked ?? 0];
1242
+ if (f.go === 'review' && reviewable) {
1243
+ readReview();
1244
+ return h('section', { class: 'fx-task__fixitem', 'aria-label': f.label }, reviewBody);
1245
+ }
1246
+ return h('section', { class: 'fx-task__fixitem', 'aria-label': f.label },
1247
+ h('strong', {}, f.label), h('p', {}, f.text), f.todo ? h('p', { class: 'fx-dim' }, f.todo) : null, fixLink(f.go));
1248
+ }
1249
+ const sideButton = h('button', {
1250
+ class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Side panel', title: 'Side panel', 'aria-pressed': 'false',
1251
+ onclick: () => (side.isOpen() ? side.close() : side.open(readSide() === 'fix' && hasFix ? 'fix' : 'activity')),
1252
+ }, lucide(h, 'panel-right'));
1253
+ const side = sidecar(h, {
1254
+ label: 'Job side panel',
1255
+ tabs: [
1256
+ { id: 'activity', label: 'Activity', icon: 'history', render: () => activityBody },
1257
+ hasFix ? { id: 'fix', label: 'Fix', icon: 'triangle-alert', mark: true, render: () => { picked ??= 0; return fixBody(); } } : null,
1258
+ ].filter(Boolean),
1259
+ onChange: (tab) => {
1260
+ saveSide(tab);
1261
+ sideButton.setAttribute('aria-pressed', String(Boolean(tab)));
1262
+ if (tab !== 'fix') picked = null;
1263
+ fixChips.forEach((chip, i) => chip.setAttribute('aria-pressed', String(tab === 'fix' && picked === i)));
1264
+ },
1265
+ });
1266
+ side.element.classList.add('fx-task__side');
1216
1267
  element.append(h('div', { class: 'fx-task__grid' },
1217
1268
  h('div', { class: 'fx-task__main' },
1218
1269
  h('header', { class: 'fx-task__head' },
1219
1270
  h('nav', { class: 'fx-task__crumb', 'aria-label': 'Breadcrumb' },
1220
1271
  h('span', {}, 'Jobs'), h('span', { class: 'fx-task__slash', 'aria-hidden': 'true' }, '/'), h('span', {}, status)),
1221
- h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', onclick: close }, '✕')),
1272
+ h('div', { class: 'fx-task__tools' }, sideButton,
1273
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: close }, '✕'))),
1222
1274
  h('h2', { class: 'fx-task__title' }, clientOf(job)),
1223
- h('div', { class: 'fx-task__stage' }, stagePill(status)),
1275
+ h('div', { class: 'fx-task__stage' }, stagePill(status), fixChips),
1224
1276
  h('dl', { class: 'fx-task__fields' },
1225
1277
  field('Practitioner', job.practitioner),
1226
1278
  field(job.dueOn ? 'Due' : 'Ordered', job.dueOn ? fullDate(job.dueOn) : job.orderedOn ? fullDate(job.orderedOn) : null, late ? { class: 'fx-task__late' } : {}),
@@ -1235,7 +1287,6 @@ export async function renderJobs(main, ui) {
1235
1287
  // link to an accounting system (its Linked card).
1236
1288
  field('Invoice', invoiceText)),
1237
1289
  chooseBox,
1238
- fixBox,
1239
1290
  state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
1240
1291
  h('h3', {}, 'Custom fields'),
1241
1292
  h('dl', { class: 'fx-task__fields' }, state.fields.map((f) => h('div', {}, h('dt', {}, f.name), h('dd', {}, fieldControl(job, f)))))) : null,
@@ -1255,14 +1306,15 @@ export async function renderJobs(main, ui) {
1255
1306
  h('td', { class: 'fx-num' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))))
1256
1307
  : h('p', { class: 'fx-dim' }, 'No lines.')),
1257
1308
  h('div', { class: 'fx-task__actions' }, actions(job, close))),
1258
- h('aside', { class: 'fx-task__activity', 'aria-label': 'Activity' },
1259
- h('h3', {}, 'Activity'),
1260
- o ? activity : h('p', { class: 'fx-dim' }, `Flow has no invoice record for this job yet. It makes one when ${system} reports the job complete.`))));
1309
+ side.element));
1261
1310
  element.addEventListener('click', (event) => { if (event.target === element) close(); });
1262
1311
  element.addEventListener('close', () => element.remove());
1263
1312
  document.body.append(element);
1264
1313
  element.showModal();
1265
- if (atFix && (chooseBox ?? fixBox)) (chooseBox ?? fixBox).scrollIntoView({ block: 'start' });
1314
+ const last = readSide();
1315
+ if (atFix && hasFix) toggleFix(0);
1316
+ else if (last !== 'closed') side.open(last === 'fix' && hasFix ? 'fix' : 'activity');
1317
+ if (atFix && chooseBox) chooseBox.scrollIntoView({ block: 'start' });
1266
1318
  if (!o) return;
1267
1319
  const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
1268
1320
  flow(`/api/invoicing/jobs/${o.id}/activity`).then(({ events }) => {