@arcforgelabs/flow 2026.10.43 → 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.
package/server/ui/jobs.js CHANGED
@@ -13,6 +13,7 @@
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';
18
19
  import { jobChangeReview } from '/assets/ui/job-change.js';
@@ -23,6 +24,8 @@ import {
23
24
  COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
24
25
  normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
25
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';
26
29
  import { sidecar } from '/assets/ui/sidecar.js';
27
30
  import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
28
31
 
@@ -30,7 +33,7 @@ const LAYOUT_KEY = 'flow_jobs_layout';
30
33
  const FOLDED_KEY = 'flow_jobs_folded';
31
34
  // The job panel's sidecar: the tab last open, or closed.
32
35
  const SIDE_KEY = 'flow_job_side';
33
- const SIDE_TABS = ['activity', 'review', 'closed'];
36
+ const SIDE_TABS = ['activity', 'fix', 'closed'];
34
37
  const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
35
38
  // A job's events (server/core/ledger.js), in plain words.
36
39
  const ACTIVITY = {
@@ -61,11 +64,6 @@ const shortDate = (iso) => {
61
64
  if (days === -1) return 'Yesterday';
62
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' });
63
66
  };
64
- // A person's badge: their initials in a circle; the name on hover.
65
- const initialsOf = (name) => {
66
- const parts = (name ?? '?').split(/[\s._-]+/).filter(Boolean);
67
- return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
68
- };
69
67
  const clientOf = (job) => job.clientName ?? 'No client';
70
68
  // The one date a card shows: when the job is due, else when it was ordered.
71
69
  const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
@@ -108,7 +106,7 @@ const CHEVRON_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidde
108
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>';
109
107
 
110
108
  export async function renderJobs(main, ui) {
111
- const { h, flow, pageHeader, notice } = ui;
109
+ const { h, flow, listHead, listFrame, edgeFade, notice } = ui;
112
110
  // Search (search.js) opens a job with no invoice as /jobs?q=<case>.
113
111
  const asked = new URLSearchParams(location.search).get('q') ?? '';
114
112
  // The person's own view (job-view.js), saved with their account. Before
@@ -126,23 +124,26 @@ export async function renderJobs(main, ui) {
126
124
  // The list's ticked jobs (by jobKey), and its folded groups by group key.
127
125
  ticked: new Set(), folded: readFolded(),
128
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);
129
132
  const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
130
133
  const find = findBox(h, { label: 'Find a case, client or practitioner', value: asked, oninput: (words) => { state.q = words.trim(); draw(); } });
131
134
  const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
132
135
  const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
133
136
  // In the list, ticked rows get the same floating bar as Invoices.
134
137
  const bulk = bulkBar(ui, { label: 'Ticked jobs' });
135
- main.append(
136
- pageHeader('Jobs'),
137
- h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, find.element, toggle)),
138
- host,
139
- 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);
140
140
 
141
141
  const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
142
142
  type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
143
143
  onclick: () => { saveLayout(id); change({ layout: id }); drawToggle(); },
144
144
  }, label)));
145
145
  drawToggle();
146
+ views.draw();
146
147
 
147
148
  const problemsOf = (job) => fixes(job.obligation);
148
149
  const jobKey = (job) => `${job.sourceSystem}\u0000${job.sourceJobRef}`;
@@ -169,17 +170,13 @@ export async function renderJobs(main, ui) {
169
170
 
170
171
  // A change to the view: draw it now, save it with the account shortly
171
172
  // after, so a few quick clicks make one request.
172
- let saveTimer = null;
173
+ const saveView = viewSaver(ui, 'jobs');
173
174
  function change(patch) {
174
175
  state.view = normaliseJobsView({ ...state.view, ...patch });
175
- if (ui.me) ui.me.views = { ...ui.me.views, jobs: state.view };
176
+ views.draw();
176
177
  drawControls();
177
178
  draw();
178
- clearTimeout(saveTimer);
179
- saveTimer = setTimeout(() => {
180
- flow('/api/me/views/jobs', { method: 'PUT', body: JSON.stringify(state.view) })
181
- .catch(() => ui.toast?.('Flow could not save your view. It applies on this page only.', 'danger'));
182
- }, 500);
179
+ saveView(state.view);
183
180
  }
184
181
 
185
182
  // The chips (view-bar.js), shared with every list page.
@@ -241,11 +238,11 @@ export async function renderJobs(main, ui) {
241
238
  }, { rows: true, clearLabel: 'Clear the sort' });
242
239
 
243
240
  // ── Filters ──
244
- // Rows of field, operator and value, with a bin on each, "Add filter",
245
- // 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.
246
243
  const filterFields = () => [
247
244
  ...Object.entries(FILTER_FIELDS).filter(([id]) => id !== 'assignee' || assigneeField())
248
- .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 })),
249
246
  { group: 'Custom fields', options: customColumns().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
250
247
  ];
251
248
  const kindOf = (field) => {
@@ -253,20 +250,23 @@ export async function renderJobs(main, ui) {
253
250
  if (Object.hasOwn(FILTER_FIELDS, field)) return FILTER_FIELDS[field].kind;
254
251
  return KIND_OF_TYPE[fieldById(fieldIdOf(field))?.type] ?? null;
255
252
  };
256
- const PROBLEM_OPS = { yes: 'has one', no: 'has none' };
257
- 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)] ?? {});
258
258
  // The choices a choice or person filter offers: [{ value, label }].
259
259
  function choicesOf(field) {
260
260
  const people = () => [{ value: 'me', label: 'Me' }, ...state.people.map((p) => ({ value: p.id, label: p.name }))];
261
261
  const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
262
- 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) }));
263
263
  if (field === 'stage') return Object.values(STAGE_GROUPS).map((v) => ({ value: v, label: v }));
264
264
  if (field === 'client') return labels(state.jobs.map(clientOf));
265
265
  if (field === 'practitioner') return labels(state.jobs.map((job) => job.practitioner));
266
266
  if (field === 'assignee') return people();
267
267
  const custom = fieldById(fieldIdOf(field));
268
268
  if (custom?.type === 'person') return people();
269
- 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 }));
270
270
  }
271
271
  // A job's value for a filter field, as the filter compares it.
272
272
  function filterValue(job, field) {
@@ -350,6 +350,7 @@ export async function renderJobs(main, ui) {
350
350
  const shown = new Set(found.map(jobKey));
351
351
  for (const key of state.ticked) if (!shown.has(key)) state.ticked.delete(key);
352
352
  state.found = found;
353
+ frame.count(`${found.length} ${found.length === 1 ? 'job' : 'jobs'}`);
353
354
  host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
354
355
  drawBulk();
355
356
  }
@@ -468,6 +469,9 @@ export async function renderJobs(main, ui) {
468
469
  h('span', { class: 'fx-board__sum' }, jobs.length ? formatCents(totalOf(jobs)) : '')),
469
470
  h('div', { class: 'fx-board__cards' }, jobs.length ? jobs.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')))));
470
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'] }));
471
475
  return element;
472
476
  }
473
477
 
@@ -664,10 +668,10 @@ export async function renderJobs(main, ui) {
664
668
  const label = field.name;
665
669
  if (field.type === 'dropdown' || field.type === 'person') {
666
670
  const choices = field.type === 'person'
667
- ? state.people.map((p) => ({ value: p.id, label: p.name }))
671
+ ? state.people.map((p) => ({ value: p.id, label: p.name, title: p.name }))
668
672
  : field.options.filter((o) => !o.archived || o.id === current).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name }));
669
673
  const select = h('select', { class: 'fx-select fx-task__input', 'aria-label': label },
670
- 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))));
671
675
  select.addEventListener('change', () => save(select.value || null));
672
676
  return select;
673
677
  }
@@ -705,9 +709,6 @@ export async function renderJobs(main, ui) {
705
709
  return h('div', { class: 'fx-task__move' }, wait, select);
706
710
  }
707
711
 
708
- // A column's menu (a click on its head, or a right-click): sort by it,
709
- // move it, hide it. Client always shows. `column` null is the + menu: the
710
- // hidden columns, to add one back.
711
712
  // What a column's head sorts by: a built-in key, or the column's custom
712
713
  // field (Assignee is the lab's person field).
713
714
  const SORT_OF = { client: 'client', practitioner: 'practitioner', due: 'due', ordered: 'ordered', shipped: 'shipped', total: 'total' };
@@ -757,65 +758,43 @@ export async function renderJobs(main, ui) {
757
758
  on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
758
759
  }
759
760
  function sortMenuAt(event, by, label, on, at) {
760
- const close = () => columnPop.hidePopover?.();
761
761
  const { sorts } = state.view;
762
762
  const flip = () => change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)) });
763
- columnPop.replaceChildren(
764
- 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(); } },
765
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))),
766
- h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ sorts: sorts.filter((_, n) => n !== at) }); } },
767
- h('span', { class: 'fx-pop__tick' }, lucide(h, 'ban')), h('span', {}, 'Clear sort')));
768
- placeColumnPop(event);
769
- }
770
- const columnPop = h('div', { class: 'fx-pop', id: 'fx-jobs-colmenu', popover: 'auto', role: 'menu' });
771
- document.body.append(columnPop);
772
- // A popover opened while the right button is still down closes again on
773
- // its release (light dismiss), so open it after the release.
774
- function onRightButton(event, open) {
775
- if (event.buttons & 2) addEventListener('pointerup', () => setTimeout(open), { once: true });
776
- else open();
766
+ heads.menu.item('ban', 'Clear sort', () => change({ sorts: sorts.filter((_, n) => n !== at) })),
767
+ ], event);
777
768
  }
778
- function columnMenu(column, event) {
779
- const ids = state.view.columns;
780
- const close = () => columnPop.hidePopover?.();
781
- const item = (icon, text, run) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); run(); } },
782
- h('span', { class: 'fx-pop__tick' }, lucide(h, icon)), h('span', {}, text));
783
- const parts = [];
784
- 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 = [];
785
786
  const sort = sortOf(column.id);
786
787
  if (sort) {
787
788
  const [up, down] = dirWords(sort);
788
789
  parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sorts: [{ by: sort, dir: 'asc' }] })),
789
790
  item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sorts: [{ by: sort, dir: 'desc' }] })), h('div', { class: 'fx-pop__sep' }));
790
791
  }
791
- const rest = ids.filter((c) => c !== column.id);
792
- parts.push(item('arrow-left-to-line', 'Move to start', () => change({ columns: [column.id, ...rest] })),
793
- item('arrow-right-to-line', 'Move to end', () => change({ columns: [...rest, column.id] })));
794
792
  if (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)) {
795
793
  parts.push(item('settings', 'Edit custom field', () => ui.go('/custom-fields')));
796
794
  }
797
- parts.push(h('div', { class: 'fx-pop__sep' }), item('eye-off', 'Hide column', () => change({ columns: rest })));
798
- }
799
- // + lists the hidden columns: a click adds one at the end.
800
- if (!column) {
801
- const shown = new Set(ids);
802
- const hidden = [
803
- ...builtInColumns().filter((id) => !shown.has(id)).map((id) => ({ id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id] })),
804
- ...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
805
- ];
806
- parts.push(...hidden.map((c) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ columns: [...ids, c.id] }); } }, c.label)));
807
- if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
808
- }
809
- columnPop.replaceChildren(...parts);
810
- placeColumnPop(event);
811
- }
812
- function placeColumnPop(event) {
813
- columnPop.showPopover?.();
814
- const x = event.clientX || event.currentTarget?.getBoundingClientRect?.().left || 0;
815
- const y = event.clientY || event.currentTarget?.getBoundingClientRect?.().bottom || 0;
816
- columnPop.style.top = `${Math.round(Math.min(y + 4, innerHeight - columnPop.offsetHeight - 8))}px`;
817
- columnPop.style.left = `${Math.round(Math.max(8, Math.min(x, innerWidth - columnPop.offsetWidth - 8)))}px`;
818
- }
795
+ return parts;
796
+ },
797
+ });
819
798
 
820
799
  // The list's columns, in the order the person chose them. One column holds
821
800
  // one field; each date has its own column.
@@ -823,25 +802,30 @@ export async function renderJobs(main, ui) {
823
802
  const day = (iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
824
803
  const late = (job) => (isLate(job) ? h('span', { class: 'fx-job__date--late', title: 'Past due' }, day(job.dueOn)) : day(job.dueOn));
825
804
  const BUILT_IN = {
826
- case: { kind: 'case', dim: true, cell: (job) => caseNumber(job) ?? '—' },
827
- practitioner: { kind: 'text', dim: true, cell: (job) => job.practitioner ?? '—' },
828
- 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 ?? '—' },
829
808
  stage: { kind: 'stage', cell: (job) => stagePill(statusOf(job)) },
830
809
  assignee: { kind: 'person', cell: (job) => personBadge(job, assigneeField()) },
831
- ordered: { kind: 'date', dim: true, cell: (job) => day(job.orderedOn) },
832
- due: { kind: 'date', dim: true, cell: late },
833
- shipped: { kind: 'date', dim: true, cell: (job) => day(job.shippedOn) },
834
- 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)) },
835
814
  };
836
815
  const allowed = new Set(builtInColumns());
837
- 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) => {
838
822
  if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
839
823
  const field = customColumns().find((f) => fieldGroup(f.id) === id);
840
824
  if (!field) return null;
841
825
  const cell = field.type === 'person' ? (job) => personBadge(job, field)
842
826
  : field.type === 'date' ? (job) => day(valueOf(job, field.id)?.value ?? null) : (job) => fieldText(job, field);
843
- 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 };
844
- }).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) }))];
845
829
  }
846
830
 
847
831
  // A person custom field's value as a badge (initials), the name on hover.
@@ -898,8 +882,7 @@ export async function renderJobs(main, ui) {
898
882
  onkeydown: (event) => { if (event.key === 'Enter' && event.target === event.currentTarget) openJob(job); },
899
883
  },
900
884
  tickCell(setBox([job], `Tick ${clientOf(job)}${caseNumber(job) ? `, case ${caseNumber(job)}` : ''}`)),
901
- h('td', { class: 'fx-c-client' }, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
902
- 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),
903
886
  h('td', { class: 'fx-c-end' }));
904
887
  const fold = (label) => {
905
888
  const next = new Set(state.folded[state.view.group] ?? []);
@@ -917,22 +900,16 @@ export async function renderJobs(main, ui) {
917
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));
918
901
  return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: () => fold(label) },
919
902
  tickCell(setBox(jobs, `Tick every job in ${label}`)),
920
- 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))))));
921
904
  };
922
- return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
923
- h('thead', {}, h('tr', {},
924
- h('th', { class: 'fx-tick-cell' }, setBox(allJobs, 'Tick every job shown')),
925
- h('th', {}, h('span', { class: 'fx-th-cell' }, h('span', { class: 'fx-th fx-th--fixed' }, 'Client'), sortButton('client', 'Client'))),
926
- columns.map((c) => h('th', { class: c.num ? 'fx-num' : null, oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => columnMenu(c, event)); } },
927
- h('span', { class: 'fx-th-cell' },
928
- h('button', { type: 'button', class: 'fx-th', 'aria-haspopup': 'menu', title: 'Column menu (or right-click)', onclick: (event) => columnMenu(c, event) }, c.label),
929
- sortButton(c.id, c.label)))),
930
- h('th', { class: 'fx-th-add' }, h('button', {
931
- type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => columnMenu(null, event),
932
- }, '+')))),
933
- 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', {},
934
910
  g.label == null ? null : head(g),
935
- 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
+ }));
936
913
  }
937
914
 
938
915
  // ── Bulk actions ─────────────────────────────────────────────────────────
@@ -1101,7 +1078,7 @@ export async function renderJobs(main, ui) {
1101
1078
  if (!o) return [];
1102
1079
  if (o.invoiceId) {
1103
1080
  const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
1104
- // A changed job is accepted in the panel's Review sidecar.
1081
+ // A changed job is accepted in the panel's sidecar (Fix tab).
1105
1082
  return [h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label)];
1106
1083
  }
1107
1084
  // Flow's own stage: a person marks a job ready to invoice, or an
@@ -1146,10 +1123,10 @@ export async function renderJobs(main, ui) {
1146
1123
  // The job's page: a large panel over the board, like a task page. A
1147
1124
  // breadcrumb, the client's name, the stage, the job's fields, what to fix
1148
1125
  // and its lines. At the side (below on a phone), the sidecar: Activity,
1149
- // and Review when the job changed after its draft (what changed, and
1150
- // Accept change). The sidecar closes, and the main area takes its room.
1151
- // Esc, the X or a click outside closes the panel. `atFix` scrolls to what
1152
- // to fix, and opens Review when there is one. The job's custom fields can
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
1153
1130
  // be changed here (in Flow only), and its stage moved (a hand-off to the
1154
1131
  // work system).
1155
1132
  function openJob(job, { atFix = false } = {}) {
@@ -1157,7 +1134,7 @@ export async function renderJobs(main, ui) {
1157
1134
  const stage = invoiceStage(o);
1158
1135
  const found = problemsOf(job);
1159
1136
  const choose = found.find((f) => f.go === 'choose') ?? null;
1160
- // The job changed after its draft: the sidecar has a Review tab.
1137
+ // The job changed after its draft: its Fix shows the change.
1161
1138
  const reviewable = Boolean(o?.invoiceId) && found.some((f) => f.go === 'review');
1162
1139
  const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
1163
1140
  const status = job.status ?? NO_STATUS;
@@ -1173,14 +1150,24 @@ export async function renderJobs(main, ui) {
1173
1150
  : choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
1174
1151
  : stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
1175
1152
  const rest = found.filter((f) => f.go !== 'choose');
1176
- // One short line a fix: its label, what is wrong, and its link. What to
1177
- // do is the link's tip.
1178
- const fixBox = rest.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
1179
- h('h3', {}, 'Fix'),
1180
- rest.map((f) => {
1181
- const go = fixLink(f.go, f.todo);
1182
- return h('div', { class: 'fx-task__fixrow', title: go ? null : f.todo }, h('strong', {}, f.label), h('span', {}, f.text), go);
1183
- })) : 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
+ }
1184
1171
  // No invoice, and the match is unclear: one line and two buttons. The
1185
1172
  // possible matches are compared on the Reconcile page, not here.
1186
1173
  const chooseBox = choose ? h('section', { class: 'fx-task__choose', 'aria-label': 'Invoice' },
@@ -1209,25 +1196,22 @@ export async function renderJobs(main, ui) {
1209
1196
  // Each fix's link: Flow's own invoice, the job in the work system, or
1210
1197
  // the Flow page where the fix is made (job-board.js GO).
1211
1198
  function fixLink(go, tip = null) {
1212
- const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, title: tip, onclick: (event) => { close(); ui.go(event); } }, text);
1213
- if (go === 'review' && reviewable) {
1214
- return h('button', { class: 'fx-task__link fx-task__linkbtn', type: 'button', title: tip, onclick: () => side.open('review') }, 'Review');
1215
- }
1216
- if (go === 'review' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
1217
- if (go === 'invoice' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
1218
- if (go === 'work' && job.link?.url) {
1219
- return h('a', { class: 'fx-task__link', href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`);
1220
- }
1221
- if (go === 'price-list') return inFlow('/price-list', 'Open price list');
1222
- if (go === 'invoicing-settings') return inFlow('/invoicing-settings', 'Open invoice rules');
1223
- if (go === 'integrations' && ui.me?.person?.role === 'owner') return inFlow('/integrations', 'Open integrations');
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');
1224
1208
  return null;
1225
1209
  }
1226
1210
  const lines = job.lines ?? [];
1227
1211
  const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
1228
1212
  const activityBody = h('div', { class: 'fx-task__activity' },
1229
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.`));
1230
- // Review: the job's change as the invoice page shows it (job-change.js),
1214
+ // The change: as the invoice page shows it (job-change.js),
1231
1215
  // read from the invoice when the sidecar first needs it.
1232
1216
  const reviewBody = h('div', { class: 'fx-task__review' }, h('p', { class: 'fx-dim' }, 'Loading…'));
1233
1217
  let reviewRead = false;
@@ -1251,19 +1235,32 @@ export async function renderJobs(main, ui) {
1251
1235
  close();
1252
1236
  if (fresh) openJob(fresh);
1253
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
+ }
1254
1249
  const sideButton = h('button', {
1255
1250
  class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Side panel', title: 'Side panel', 'aria-pressed': 'false',
1256
- onclick: () => (side.isOpen() ? side.close() : side.open(readSide() === 'review' && reviewable ? 'review' : 'activity')),
1251
+ onclick: () => (side.isOpen() ? side.close() : side.open(readSide() === 'fix' && hasFix ? 'fix' : 'activity')),
1257
1252
  }, lucide(h, 'panel-right'));
1258
1253
  const side = sidecar(h, {
1259
1254
  label: 'Job side panel',
1260
1255
  tabs: [
1261
1256
  { id: 'activity', label: 'Activity', icon: 'history', render: () => activityBody },
1262
- reviewable ? { id: 'review', label: 'Review', icon: 'git-compare', mark: true, render: () => { readReview(); return reviewBody; } } : null,
1257
+ hasFix ? { id: 'fix', label: 'Fix', icon: 'triangle-alert', mark: true, render: () => { picked ??= 0; return fixBody(); } } : null,
1263
1258
  ].filter(Boolean),
1264
1259
  onChange: (tab) => {
1265
1260
  saveSide(tab);
1266
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)));
1267
1264
  },
1268
1265
  });
1269
1266
  side.element.classList.add('fx-task__side');
@@ -1275,7 +1272,7 @@ export async function renderJobs(main, ui) {
1275
1272
  h('div', { class: 'fx-task__tools' }, sideButton,
1276
1273
  h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: close }, '✕'))),
1277
1274
  h('h2', { class: 'fx-task__title' }, clientOf(job)),
1278
- h('div', { class: 'fx-task__stage' }, stagePill(status)),
1275
+ h('div', { class: 'fx-task__stage' }, stagePill(status), fixChips),
1279
1276
  h('dl', { class: 'fx-task__fields' },
1280
1277
  field('Practitioner', job.practitioner),
1281
1278
  field(job.dueOn ? 'Due' : 'Ordered', job.dueOn ? fullDate(job.dueOn) : job.orderedOn ? fullDate(job.orderedOn) : null, late ? { class: 'fx-task__late' } : {}),
@@ -1290,7 +1287,6 @@ export async function renderJobs(main, ui) {
1290
1287
  // link to an accounting system (its Linked card).
1291
1288
  field('Invoice', invoiceText)),
1292
1289
  chooseBox,
1293
- fixBox,
1294
1290
  state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
1295
1291
  h('h3', {}, 'Custom fields'),
1296
1292
  h('dl', { class: 'fx-task__fields' }, state.fields.map((f) => h('div', {}, h('dt', {}, f.name), h('dd', {}, fieldControl(job, f)))))) : null,
@@ -1316,9 +1312,9 @@ export async function renderJobs(main, ui) {
1316
1312
  document.body.append(element);
1317
1313
  element.showModal();
1318
1314
  const last = readSide();
1319
- if (atFix && reviewable) side.open('review');
1320
- else if (last !== 'closed') side.open(last === 'review' && reviewable ? 'review' : 'activity');
1321
- if (atFix && (chooseBox ?? fixBox)) (chooseBox ?? fixBox).scrollIntoView({ block: 'start' });
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' });
1322
1318
  if (!o) return;
1323
1319
  const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
1324
1320
  flow(`/api/invoicing/jobs/${o.id}/activity`).then(({ events }) => {