@arcforgelabs/flow 2026.10.34 → 2026.10.36

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
@@ -1,14 +1,24 @@
1
1
  // Jobs (/jobs): the work system's jobs, as a board with one column per work
2
- // system status (its own statuses, one to one), or as a list. Cards are not
3
- // dragged: a job moves when the work system changes its status. A click on a
4
- // job opens its detail panel: the job, its lines, its invoice, and what to
5
- // fix. Data comes from /api/invoicing/board (server/core/job-board.js); the
6
- // rules are in server/ui/job-board.js.
2
+ // system status (its own statuses, one to one), or as a list. A click on a
3
+ // job opens its detail panel: the job, its lines, its invoice, its custom
4
+ // fields, and what to fix. Data comes from /api/invoicing/board
5
+ // (server/core/job-board.js); the rules are in server/ui/job-board.js and
6
+ // server/ui/job-view.js.
7
+ //
8
+ // A card can be dragged to another lane with a mouse or pen
9
+ // (docs/JOB_BOARD_MOVES.md). Grouped by a custom field, the move sets the
10
+ // job's value, in Flow only. Grouped by status, the move is a stage move
11
+ // in the work system: Seazona cannot take one through its API, so Flow
12
+ // shows what to do there and the card waits in its new lane. On a phone,
13
+ // or with a keyboard, the job's panel has the same moves.
7
14
 
8
15
  import { formatCents } from '/assets/ui/invoicing.js';
9
16
  import {
10
17
  FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
11
18
  } from '/assets/ui/job-board.js';
19
+ import {
20
+ DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, filterCount, filterJobs, groupJobs, GROUPS, laneOf, normaliseJobsView, NOT_SET, sortJobs, SORTS,
21
+ } from '/assets/ui/job-view.js';
12
22
 
13
23
  const LAYOUT_KEY = 'flow_jobs_layout';
14
24
  const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
@@ -23,6 +33,9 @@ const ACTIVITY = {
23
33
  writeback_not_supported: 'The lab system does not take the status',
24
34
  };
25
35
  const BY = { person: 'A person', automation: 'An automation' };
36
+ // Grouped by invoice stage: a job with no invoice stage yet is still in work.
37
+ const STAGE_GROUPS = { in_work: 'In work', ...INVOICE_STAGES };
38
+ const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4, Invoiced: 5, Paid: 6 };
26
39
 
27
40
  const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
28
41
  const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
@@ -46,30 +59,154 @@ const today = () => {
46
59
 
47
60
  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>';
48
61
  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
+ 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
+ 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>';
64
+ 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
+ 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>';
49
66
  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>';
50
67
 
51
68
  export async function renderJobs(main, ui) {
52
69
  const { h, flow, pageHeader, notice } = ui;
53
70
  // Search (search.js) opens a job with no invoice as /jobs?q=<case>.
54
71
  const asked = new URLSearchParams(location.search).get('q') ?? '';
55
- const state = { layout: readLayout(), q: asked.trim().toLowerCase(), jobs: [], statuses: [], accountingName: null, canDraft: false };
72
+ // The person's own view (job-view.js), saved with their account. Before
73
+ // they first change it, the layout this browser last used still applies.
74
+ const saved = ui.me?.views?.jobs;
75
+ const state = {
76
+ view: normaliseJobsView(saved ?? { ...DEFAULT_VIEW, layout: readLayout() }),
77
+ q: asked.trim().toLowerCase(), jobs: [], statuses: [], accountingName: null, canDraft: false,
78
+ // Custom fields (docs/CUSTOM_FIELDS.md): the lab's fields, the work
79
+ // systems' stage fields, the people a person field can name, and each
80
+ // job's values by job and field.
81
+ fields: [], systemFields: [], people: [], values: new Map(),
82
+ // Each work system's "Ask before stage moves", read on the first move.
83
+ askBefore: new Map(),
84
+ };
56
85
  const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
57
86
  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 });
58
87
  const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
88
+ const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
59
89
  main.classList.add('fx-main--wide');
60
90
  main.append(
61
91
  pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
62
- h('div', { class: 'fx-toolbar fx-toolbar--plain fx-toolbar--end' }, search, toggle),
92
+ h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, search, toggle)),
63
93
  host);
64
94
 
65
95
  const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
66
- type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.layout === id),
67
- onclick: () => { state.layout = id; saveLayout(id); drawToggle(); draw(); },
96
+ type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
97
+ onclick: () => { saveLayout(id); change({ layout: id }); drawToggle(); },
68
98
  }, label)));
69
99
  drawToggle();
70
100
  search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
71
101
 
72
102
  const problemsOf = (job) => fixes(job.obligation, { accountingName: state.accountingName });
103
+ const valueKey = (job, fieldId) => `${job.sourceSystem}\u0000${job.sourceJobRef}\u0000${fieldId}`;
104
+ const valueOf = (job, fieldId) => state.values.get(valueKey(job, fieldId)) ?? null;
105
+ const groupable = () => state.fields.filter((f) => f.groupable);
106
+ const fieldOfGroup = (group) => groupable().find((f) => f.id === fieldIdOf(group)) ?? null;
107
+ // A work system whose stages Flow can hand a move to (Seazona).
108
+ const movesStages = (job) => state.systemFields.some((f) => f.source === job.sourceSystem);
109
+ // The lane a job shows in when grouped by status: a stage move the work
110
+ // system has not made yet puts it in its new lane.
111
+ const laneStatusOf = (job) => job.pendingMove?.to ?? job.status ?? NO_STATUS;
112
+ const statusOf = (job) => job.status ?? NO_STATUS;
113
+ const isLate = (job) => Boolean(job.dueOn && job.dueOn < today() && statusRank(job.status) < FINAL_RANK);
114
+
115
+ // A change to the view: draw it now, save it with the account shortly
116
+ // after, so a few quick clicks make one request.
117
+ let saveTimer = null;
118
+ function change(patch) {
119
+ state.view = normaliseJobsView({ ...state.view, ...patch });
120
+ if (ui.me) ui.me.views = { ...ui.me.views, jobs: state.view };
121
+ drawControls();
122
+ draw();
123
+ clearTimeout(saveTimer);
124
+ saveTimer = setTimeout(() => {
125
+ flow('/api/me/views/jobs', { method: 'PUT', body: JSON.stringify(state.view) })
126
+ .catch(() => ui.toast?.('Flow could not save your view. It applies on this page only.', 'danger'));
127
+ }, 500);
128
+ }
129
+
130
+ // One toolbar control: a button that opens a small menu under it. The menu
131
+ // is a popover, so a click outside or Escape closes it.
132
+ let menuCount = 0;
133
+ function menu(icon, build) {
134
+ const id = `fx-jobs-menu-${++menuCount}`;
135
+ const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
136
+ const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' });
137
+ const fill = () => panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
138
+ panel.addEventListener('toggle', (event) => {
139
+ button.setAttribute('aria-expanded', String(event.newState === 'open'));
140
+ if (event.newState !== 'open') return;
141
+ fill();
142
+ const at = button.getBoundingClientRect();
143
+ panel.style.top = `${Math.round(at.bottom + 6)}px`;
144
+ panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
145
+ });
146
+ return {
147
+ node: [button, panel],
148
+ set(label, on) {
149
+ button.replaceChildren(svg(icon), h('span', {}, label));
150
+ button.classList.toggle('fx-chip--on', on);
151
+ },
152
+ };
153
+ }
154
+
155
+ // A choice in a menu: a row with a tick when it is chosen.
156
+ const option = (text, checked, onclick, role = 'menuitemradio') => h('button', {
157
+ type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
158
+ }, h('span', { class: 'fx-pop__tick' }, checked ? svg(TICK_ICON) : null), h('span', {}, text));
159
+ const heading = (text) => h('p', { class: 'fx-pop__head' }, text);
160
+ const DIR_WORDS = { total: ['Low to high', 'High to low'], due: ['Oldest first', 'Newest first'], board: ['As the lab system has it', 'Reversed'] };
161
+
162
+ const groupMenu = menu(GROUP_ICON, (_redraw, close) => [
163
+ heading('Group by'),
164
+ ...Object.entries(GROUPS).map(([id, text]) => option(text, state.view.group === id, () => { change({ group: id }); close(); })),
165
+ groupable().length ? h('div', { class: 'fx-pop__sep' }) : null,
166
+ groupable().length ? heading('Custom fields') : null,
167
+ ...groupable().map((f) => option(f.name, state.view.group === fieldGroup(f.id), () => { change({ group: fieldGroup(f.id) }); close(); })),
168
+ ]);
169
+ const sortMenu = menu(SORT_ICON, (redraw) => {
170
+ const [up, down] = DIR_WORDS[state.view.sort] ?? ['A to Z', 'Z to A'];
171
+ return [
172
+ heading('Sort by'),
173
+ ...Object.entries(SORTS).map(([id, text]) => option(text, state.view.sort === id, () => { change({ sort: id }); redraw(); })),
174
+ h('div', { class: 'fx-pop__sep' }),
175
+ option(up, state.view.dir === 'asc', () => { change({ dir: 'asc' }); redraw(); }),
176
+ option(down, state.view.dir === 'desc', () => { change({ dir: 'desc' }); redraw(); }),
177
+ ];
178
+ });
179
+ const filterMenu = menu(FILTER_ICON, (redraw) => {
180
+ const hidden = new Set(state.view.hide);
181
+ return [
182
+ heading('Show only'),
183
+ option('Late jobs', state.view.late, () => { change({ late: !state.view.late }); redraw(); }, 'menuitemcheckbox'),
184
+ option('Jobs with a problem', state.view.problems, () => { change({ problems: !state.view.problems }); redraw(); }, 'menuitemcheckbox'),
185
+ h('div', { class: 'fx-pop__sep' }),
186
+ heading('Statuses'),
187
+ ...orderStatuses(state.statuses, state.jobs).map((label) => option(label, !hidden.has(label), () => {
188
+ const next = new Set(hidden);
189
+ if (next.has(label)) next.delete(label); else next.add(label);
190
+ change({ hide: [...next] });
191
+ redraw();
192
+ }, 'menuitemcheckbox')),
193
+ filterCount(state.view) ? h('div', { class: 'fx-pop__foot' }, h('button', {
194
+ type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick: () => { change({ hide: [], late: false, problems: false }); redraw(); },
195
+ }, 'Clear filters')) : null,
196
+ ];
197
+ });
198
+ controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node);
199
+
200
+ function drawControls() {
201
+ const { group, sort, dir } = state.view;
202
+ const field = fieldOfGroup(group);
203
+ groupMenu.set(`Group: ${field?.name ?? GROUPS[group] ?? GROUPS.status}`, group !== DEFAULT_VIEW.group && (Boolean(field) || Boolean(GROUPS[group])));
204
+ const sorted = sort !== DEFAULT_VIEW.sort || dir !== DEFAULT_VIEW.dir;
205
+ sortMenu.set(sorted ? `Sort: ${SORTS[sort]}${dir === 'desc' ? ' ↓' : ''}` : 'Sort', sorted);
206
+ const count = filterCount(state.view);
207
+ filterMenu.set(count ? `Filter · ${count}` : 'Filter', count > 0);
208
+ }
209
+ drawControls();
73
210
 
74
211
  function draw() {
75
212
  if (!state.jobs.length) {
@@ -79,22 +216,33 @@ export async function renderJobs(main, ui) {
79
216
  h('a', { class: 'fx-btn', href: '/invoices/new', onclick: ui.go }, 'Make an invoice by hand')));
80
217
  return;
81
218
  }
82
- const shown = state.jobs.filter((job) => matches(job, state.q));
83
- main.classList.toggle('fx-main--board', state.layout === 'board');
84
- host.replaceChildren(state.layout === 'list' ? list(shown) : board(shown));
219
+ const found = filterJobs(state.jobs.filter((job) => matches(job, state.q)), state.view,
220
+ { status: statusOf, late: isLate, problem: (job) => problemsOf(job).length > 0 });
221
+ const groups = grouped(sortJobs(found, state.view));
222
+ main.classList.toggle('fx-main--board', state.view.layout === 'board');
223
+ host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
85
224
  }
86
225
 
87
226
  async function load() {
88
227
  try {
89
- const [body, context] = await Promise.all([flow('/api/invoicing/board'), flow('/api/invoicing/context')]);
228
+ // Custom fields are extra: the board still shows without them.
229
+ const [body, context, fields, values] = await Promise.all([
230
+ flow('/api/invoicing/board'), flow('/api/invoicing/context'),
231
+ flow('/api/custom-fields').catch(() => null), flow('/api/custom-fields/values').catch(() => null),
232
+ ]);
90
233
  state.jobs = body.jobs;
91
234
  state.statuses = body.statuses;
92
235
  state.accountingName = body.accountingName;
93
236
  state.canDraft = context.canIssue;
237
+ state.fields = fields?.fields ?? [];
238
+ state.systemFields = fields?.systemFields ?? [];
239
+ state.people = fields?.people ?? [];
240
+ state.values = new Map((values?.values ?? []).map((v) => [valueKey(v, v.fieldId), { value: v.value, updatedAt: v.updatedAt }]));
94
241
  } catch (error) {
95
242
  host.replaceChildren(notice(error.message, 'danger'));
96
243
  return;
97
244
  }
245
+ drawControls();
98
246
  draw();
99
247
  }
100
248
 
@@ -113,15 +261,16 @@ export async function renderJobs(main, ui) {
113
261
  // One card ("Task card"). The card design may change, so its markup stays
114
262
  // here and its CSS in one block of flow.css ("Job card"): client,
115
263
  // practitioner, one date, total, and a warning mark when the job has a
116
- // problem. Never the case number. A late job's date shows in red. No
117
- // "move to the next stage" tick yet: Flow only reads the lab system.
264
+ // problem. Never the case number. A late job's date shows in red. A stage
265
+ // move the work system has not made yet adds "Waiting for <system>".
118
266
  function jobCard(job) {
119
267
  const date = dateOf(job);
120
268
  const day = job.dueOn ?? job.orderedOn ?? job.jobDate;
121
- const late = job.dueOn && job.dueOn < today() && statusRank(job.status) < FINAL_RANK;
122
- return h('article', {
269
+ const late = isLate(job);
270
+ const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
271
+ const card = h('article', {
123
272
  class: 'fx-job', tabindex: '0', role: 'button', 'aria-label': `${clientOf(job)}, open the job`,
124
- onclick: () => openJob(job),
273
+ onclick: () => { if (!dragged) openJob(job); },
125
274
  onkeydown: (event) => { if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openJob(job); } },
126
275
  },
127
276
  h('div', { class: 'fx-job__client' }, clientOf(job)),
@@ -130,43 +279,317 @@ export async function renderJobs(main, ui) {
130
279
  day ? h('span', { class: `fx-job__date${late ? ' fx-job__date--late' : ''}`, title: late ? `${date}, past due` : date }, svg(CALENDAR_ICON), showDate(day)) : null,
131
280
  h('span', { class: 'fx-job__space' }),
132
281
  problemMark(job),
133
- h('span', { class: 'fx-job__amount' }, job.totalCents == null ? '—' : formatCents(job.totalCents))));
282
+ h('span', { class: 'fx-job__amount' }, job.totalCents == null ? '—' : formatCents(job.totalCents))),
283
+ job.pendingMove ? h('span', { class: 'fx-job__wait', title: `Set the status to ${job.pendingMove.to} in ${system}. Flow then reads the change.` }, `Waiting for ${system}`) : null);
284
+ cardJobs.set(card, job);
285
+ return card;
134
286
  }
135
287
 
136
- // A column's label: the status, in its stage colour, and how many jobs.
137
- const stagePill = (label, count = null) => h('span', { class: 'fx-stage', 'data-rank': String(statusRank(label)) },
288
+ // A group's label: in its stage colour when the group is a status or an
289
+ // invoice stage, and how many jobs.
290
+ const stagePill = (label, count = null, rank = statusRank(label)) => h('span', { class: 'fx-stage', 'data-rank': String(rank) },
138
291
  h('span', { class: 'fx-stage__label' }, label),
139
292
  count == null ? null : h('span', { class: 'fx-stage__count' }, String(count)));
140
293
 
141
- function board(jobs) {
142
- const columns = orderStatuses(state.statuses, state.jobs);
143
- return h('div', { class: 'fx-board' }, columns.map((label) => {
144
- const inColumn = jobs.filter((job) => (job.status ?? NO_STATUS) === label);
145
- const total = inColumn.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
146
- return h('section', { class: 'fx-board__col', 'aria-label': label },
147
- h('header', { class: 'fx-board__head' },
148
- stagePill(label, inColumn.length),
149
- h('span', { class: 'fx-board__sum' }, inColumn.length ? formatCents(total) : '')),
150
- h('div', { class: 'fx-board__cards' }, inColumn.length ? inColumn.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')));
151
- }));
294
+ // The groups the view asks for, in order, each with its jobs. Grouped by
295
+ // status, every status the work system has shows, even with no job, except
296
+ // the ones the filter hides.
297
+ // A group's `drop` says what a card dropped in it changes: a custom
298
+ // field's value, or the job's stage in the work system. No `drop`: cards
299
+ // cannot be dropped there (invoice stage, client, practitioner).
300
+ function grouped(jobs) {
301
+ const { group } = state.view;
302
+ const field = fieldOfGroup(group);
303
+ if (field) {
304
+ const read = (job) => valueOf(job, field.id)?.value ?? null;
305
+ const lanes = fieldLanes(field, { people: state.people, jobs: state.jobs, valueOf: read });
306
+ const byLabel = new Map(lanes.map((lane) => [lane.label, lane]));
307
+ return groupJobs(jobs, lanes.map((lane) => lane.label), (job) => laneOf(lanes, read(job))).map((g) => {
308
+ const lane = byLabel.get(g.label);
309
+ return { ...g, rank: 'plain', color: lane?.color, drop: lane && !lane.archived ? { kind: 'field', field, value: lane.value } : null };
310
+ });
311
+ }
312
+ if (group === 'status' || !GROUPS[group]) {
313
+ const hidden = new Set(state.view.hide);
314
+ return groupJobs(jobs, orderStatuses(state.statuses, state.jobs).filter((label) => !hidden.has(label)), laneStatusOf)
315
+ .map((g) => ({ ...g, rank: statusRank(g.label), drop: g.label === NO_STATUS ? null : { kind: 'stage', status: g.label } }));
316
+ }
317
+ if (group === 'stage') {
318
+ const labelOf = (job) => STAGE_GROUPS[invoiceStage(job.obligation) ?? 'in_work'];
319
+ return groupJobs(jobs, Object.values(STAGE_GROUPS), labelOf).map((g) => ({ ...g, rank: STAGE_RANK[g.label] }));
320
+ }
321
+ const labelOf = group === 'client' ? clientOf : (job) => job.practitioner ?? 'Not listed';
322
+ const labels = [...new Set(jobs.map(labelOf))].sort((x, y) => x.localeCompare(y));
323
+ return groupJobs(jobs, labels, labelOf).map((g) => ({ ...g, rank: 'plain' }));
324
+ }
325
+
326
+ const totalOf = (jobs) => jobs.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
327
+
328
+ function board(groups) {
329
+ if (!groups.length) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
330
+ const element = h('div', { class: 'fx-board' }, groups.map(({ label, jobs, rank, color }, index) => h('section', { class: 'fx-board__col', 'aria-label': label, 'data-lane': String(index) },
331
+ h('header', { class: 'fx-board__head' },
332
+ 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),
333
+ h('span', { class: 'fx-board__sum' }, jobs.length ? formatCents(totalOf(jobs)) : '')),
334
+ h('div', { class: 'fx-board__cards' }, jobs.length ? jobs.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')))));
335
+ if (groups.some((g) => g.drop)) enableDrag(element, groups);
336
+ return element;
337
+ }
338
+
339
+ // ── Moves ────────────────────────────────────────────────────────────────
340
+ // Drag with a mouse or pen: press on a card, move 6px, and a copy of the
341
+ // card follows the pointer. A lane that takes the card lights up. Touch
342
+ // keeps scrolling the board; the job's panel has the moves instead.
343
+ const cardJobs = new WeakMap();
344
+ let dragged = false;
345
+ function enableDrag(element, groups) {
346
+ let drag = null;
347
+ const laneAt = (x, y) => document.elementFromPoint(x, y)?.closest?.('.fx-board__col') ?? null;
348
+ const canDrop = (lane, job) => {
349
+ if (!lane || !drag) return false;
350
+ const target = groups[Number(lane.dataset.lane)];
351
+ if (!target?.drop || Number(lane.dataset.lane) === drag.from) return false;
352
+ return target.drop.kind === 'field' || movesStages(job);
353
+ };
354
+ const end = () => {
355
+ drag?.ghost?.remove();
356
+ drag?.card.classList.remove('fx-job--lifted');
357
+ element.classList.remove('fx-board--dragging');
358
+ element.querySelectorAll('.fx-board__col--over').forEach((lane) => lane.classList.remove('fx-board__col--over'));
359
+ drag = null;
360
+ };
361
+ element.addEventListener('pointerdown', (event) => {
362
+ if (event.button !== 0 || event.pointerType === 'touch') return;
363
+ const card = event.target.closest('.fx-job');
364
+ if (!card || event.target.closest('button, a')) return;
365
+ const rect = card.getBoundingClientRect();
366
+ drag = { card, job: cardJobs.get(card), from: Number(card.closest('.fx-board__col').dataset.lane), x: event.clientX, y: event.clientY, dx: event.clientX - rect.left, dy: event.clientY - rect.top, width: rect.width, moved: false, over: null };
367
+ card.setPointerCapture(event.pointerId);
368
+ });
369
+ element.addEventListener('pointermove', (event) => {
370
+ if (!drag) return;
371
+ if (!drag.moved) {
372
+ if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 6) return;
373
+ drag.moved = true;
374
+ drag.ghost = drag.card.cloneNode(true);
375
+ drag.ghost.classList.add('fx-job--ghost');
376
+ drag.ghost.style.width = `${drag.width}px`;
377
+ document.body.append(drag.ghost);
378
+ drag.card.classList.add('fx-job--lifted');
379
+ element.classList.add('fx-board--dragging');
380
+ }
381
+ drag.ghost.style.transform = `translate(${event.clientX - drag.dx}px, ${event.clientY - drag.dy}px) rotate(1.5deg)`;
382
+ const lane = laneAt(event.clientX, event.clientY);
383
+ const over = canDrop(lane, drag.job) ? lane : null;
384
+ if (over !== drag.over) {
385
+ drag.over?.classList.remove('fx-board__col--over');
386
+ over?.classList.add('fx-board__col--over');
387
+ drag.over = over;
388
+ }
389
+ });
390
+ element.addEventListener('pointerup', (event) => {
391
+ if (!drag) return;
392
+ const { moved, over, job } = drag;
393
+ const target = over ? groups[Number(over.dataset.lane)] : null;
394
+ end();
395
+ if (!moved) return;
396
+ // The click that ends a drag does not open the job.
397
+ dragged = true;
398
+ setTimeout(() => { dragged = false; }, 0);
399
+ if (target) moveJob(job, target.drop);
400
+ event.preventDefault();
401
+ });
402
+ element.addEventListener('pointercancel', end);
403
+ element.addEventListener('keydown', (event) => { if (event.key === 'Escape' && drag) end(); });
404
+ }
405
+
406
+ function moveJob(job, drop) {
407
+ if (drop.kind === 'field') return setValue(job, drop.field, drop.value);
408
+ return moveStage(job, drop.status);
152
409
  }
153
410
 
154
- function list(jobs) {
155
- if (!jobs.length) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches that search.'));
411
+ // Set one job's custom field value, in Flow only. The board shows it at
412
+ // once; `expectedUpdatedAt` makes a change someone else made first win,
413
+ // and the board then shows theirs.
414
+ async function setValue(job, field, value) {
415
+ const key = valueKey(job, field.id);
416
+ const before = state.values.get(key) ?? null;
417
+ if ((before?.value ?? null) === value) return;
418
+ if (value == null) state.values.delete(key); else state.values.set(key, { value, updatedAt: before?.updatedAt ?? null });
419
+ draw();
420
+ const keep = (saved) => {
421
+ if (saved?.value == null) state.values.delete(key); else state.values.set(key, { value: saved.value, updatedAt: saved.updatedAt });
422
+ };
423
+ try {
424
+ const { value: saved } = await flow('/api/custom-fields/values', {
425
+ method: 'PUT',
426
+ body: JSON.stringify({ sourceSystem: job.sourceSystem, sourceJobRef: job.sourceJobRef, fieldId: field.id, value, expectedUpdatedAt: before?.updatedAt ?? null }),
427
+ });
428
+ keep(saved);
429
+ } catch (error) {
430
+ if (error.code === 'conflict' && error.details) {
431
+ keep(error.details.current);
432
+ ui.toast?.(`Someone changed ${field.name} on this job just now. The board shows their change.`, 'info');
433
+ } else {
434
+ if (before) state.values.set(key, before); else state.values.delete(key);
435
+ ui.toast?.(error.message, 'danger');
436
+ }
437
+ }
438
+ draw();
439
+ }
440
+
441
+ // "Ask before stage moves", this person's own setting in the work system's
442
+ // integration. On unless they said "Don't ask again".
443
+ async function askBefore(system) {
444
+ if (!state.askBefore.has(system)) {
445
+ try {
446
+ const { integrations } = await flow(`/api/work/my-settings?integration=${encodeURIComponent(system)}`);
447
+ state.askBefore.set(system, integrations[0]?.settings.find((s) => s.key === 'askBeforeStageMoves')?.value ?? true);
448
+ } catch {
449
+ state.askBefore.set(system, true);
450
+ }
451
+ }
452
+ return state.askBefore.get(system);
453
+ }
454
+
455
+ // A stage move in the work system. Seazona cannot take it through its
456
+ // API, so Flow says what to do there, records the move, and the card waits
457
+ // in its new lane until the sync reads the new status. A move back to the
458
+ // job's own status takes a waiting move back.
459
+ async function moveStage(job, status) {
460
+ const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
461
+ const ref = { sourceSystem: job.sourceSystem, sourceJobRef: job.sourceJobRef };
462
+ const before = job.pendingMove ?? null;
463
+ if (status === (job.status ?? NO_STATUS)) {
464
+ if (!before) return;
465
+ job.pendingMove = null;
466
+ draw();
467
+ try {
468
+ await flow('/api/work/moves/cancel', { method: 'POST', body: JSON.stringify(ref) });
469
+ ui.toast?.(`Move taken back. The card follows ${system} again.`, 'success');
470
+ } catch (error) {
471
+ job.pendingMove = before;
472
+ ui.toast?.(error.message, 'danger');
473
+ draw();
474
+ }
475
+ return;
476
+ }
477
+ let dontAsk = false;
478
+ if (await askBefore(job.sourceSystem)) {
479
+ const tick = h('input', { type: 'checkbox', name: 'dontAsk' });
480
+ const chip = (text) => h('span', { class: 'fx-move__stage' }, text);
481
+ const body = h('div', { class: 'fx-fields fx-move' },
482
+ h('p', { class: 'fx-move__path' }, chip(job.status ?? NO_STATUS), h('span', { 'aria-hidden': 'true' }, '→'), chip(status)),
483
+ h('p', { class: 'fx-move__note' }, `${system} cannot take this change from Flow. Make it in ${system}:`),
484
+ h('ol', { class: 'fx-steps' },
485
+ h('li', {}, openStep(job, system)),
486
+ h('li', {}, `Set its status to ${status}.`),
487
+ h('li', {}, `Flow shows the card as waiting until it reads the change. After a day it goes back.`)),
488
+ h('label', { class: 'fx-check' }, tick, h('span', {}, "Don't ask again")));
489
+ const ok = await ui.dialog({ title: `Move in ${system}`, body, confirm: 'Move card' });
490
+ if (!ok) return;
491
+ dontAsk = tick.checked;
492
+ }
493
+ job.pendingMove = { to: status, from: job.status, openUrl: job.link?.url ?? null, openExact: Boolean(job.link?.exact) };
494
+ draw();
495
+ try {
496
+ const { move } = await flow('/api/work/moves', { method: 'POST', body: JSON.stringify({ ...ref, toStatus: status }) });
497
+ job.pendingMove = { to: move.to, from: move.from, requestedAt: move.requestedAt, expiresAt: move.expiresAt, openUrl: move.openUrl, openExact: move.openExact };
498
+ ui.toast?.(`Waiting for ${system}. Set case ${move.caseRef} to ${move.to} there.`, 'success');
499
+ } catch (error) {
500
+ job.pendingMove = before;
501
+ ui.toast?.(error.message, 'danger');
502
+ }
503
+ if (dontAsk) {
504
+ state.askBefore.set(job.sourceSystem, false);
505
+ flow('/api/work/my-settings', { method: 'PUT', body: JSON.stringify({ integration: job.sourceSystem, key: 'askBeforeStageMoves', value: false }) })
506
+ .catch(() => ui.toast?.('Flow could not save "Don\'t ask again".', 'danger'));
507
+ }
508
+ draw();
509
+ }
510
+
511
+ // The first step of a stage move: a link that opens the order in the work
512
+ // system's web app. An exact link opens the order itself; otherwise it
513
+ // opens the lab's home page there, and the person finds the case.
514
+ function openStep(job, system) {
515
+ const caseRef = job.caseRef ?? job.sourceJobRef;
516
+ const url = job.link?.url ?? null;
517
+ if (!url) return `Open case ${caseRef} in ${system}.`;
518
+ const out = (text) => h('a', { class: 'fx-task__link', href: url, target: '_blank', rel: 'noopener' }, text);
519
+ return job.link.exact
520
+ ? h('span', {}, out(`Open case ${caseRef} in ${system} ↗`))
521
+ : h('span', {}, out(`Open ${system} ↗`), ` and find case ${caseRef}.`);
522
+ }
523
+
524
+ // The job panel's custom fields: one control each, saved when it changes.
525
+ function fieldControl(job, field) {
526
+ const current = valueOf(job, field.id)?.value ?? null;
527
+ const save = (value) => setValue(job, field, value);
528
+ const label = field.name;
529
+ if (field.type === 'dropdown' || field.type === 'person') {
530
+ const choices = field.type === 'person'
531
+ ? state.people.map((p) => ({ value: p.id, label: p.name }))
532
+ : field.options.filter((o) => !o.archived || o.id === current).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name }));
533
+ const select = h('select', { class: 'fx-select fx-task__input', 'aria-label': label },
534
+ h('option', { value: '' }, NOT_SET), choices.map((c) => h('option', { value: c.value, selected: c.value === current }, c.label)));
535
+ select.addEventListener('change', () => save(select.value || null));
536
+ return select;
537
+ }
538
+ if (field.type === 'checkbox') {
539
+ const box = h('input', { type: 'checkbox', 'aria-label': label, checked: current === true });
540
+ box.addEventListener('change', () => save(box.checked ? true : null));
541
+ return box;
542
+ }
543
+ const input = h('input', {
544
+ class: 'fx-input fx-task__input', 'aria-label': label,
545
+ type: field.type === 'date' ? 'date' : field.type === 'number' ? 'number' : 'text',
546
+ value: current ?? '', maxlength: field.type === 'text' ? '500' : null, step: field.type === 'number' ? 'any' : null,
547
+ });
548
+ input.addEventListener('change', () => {
549
+ const raw = input.value.trim();
550
+ save(raw === '' ? null : field.type === 'number' ? Number(raw) : raw);
551
+ });
552
+ return input;
553
+ }
554
+
555
+ // The job panel's stage move: the same move as a drag, for a phone or a
556
+ // keyboard. A waiting move shows what to do, with Take back.
557
+ function stageControl(job) {
558
+ const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
559
+ const stages = state.systemFields.find((f) => f.source === job.sourceSystem)?.options ?? [];
560
+ if (!stages.length) return null;
561
+ const select = h('select', { class: 'fx-select fx-task__input', 'aria-label': `Move to a ${system} stage` },
562
+ h('option', { value: '' }, 'Move to…'),
563
+ stages.filter((s) => s.name !== job.status).map((s) => h('option', { value: s.name }, s.name)));
564
+ select.addEventListener('change', () => { if (select.value) moveStage(job, select.value).then(() => select.closest('dialog')?.close()); });
565
+ const wait = job.pendingMove ? h('div', { class: 'fx-task__wait' },
566
+ h('span', {}, `Waiting for ${system}: set the status to ${job.pendingMove.to} there.`),
567
+ job.pendingMove.openUrl ? h('a', { class: 'fx-task__link', href: job.pendingMove.openUrl, target: '_blank', rel: 'noopener' }, `Open in ${system}`) : null,
568
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: (event) => { event.currentTarget.closest('dialog')?.close(); moveStage(job, job.status ?? NO_STATUS); } }, 'Take back')) : null;
569
+ return h('div', { class: 'fx-task__move' }, wait, select);
570
+ }
571
+
572
+ // The list: one block per group, with the group's label over its rows.
573
+ function list(groups, count) {
574
+ if (!count) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
575
+ const row = (job) => h('tr', {
576
+ class: 'fx-row-link', tabindex: '0',
577
+ onclick: () => openJob(job),
578
+ onkeydown: (event) => { if (event.key === 'Enter') openJob(job); },
579
+ },
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)));
156
585
  return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
157
586
  h('thead', {}, h('tr', {},
158
587
  h('th', {}, 'Client'), h('th', {}, 'Practitioner'), h('th', {}, 'Stage'), h('th', {}, 'Date'),
159
588
  h('th', { class: 'fx-num' }, 'Total'))),
160
- h('tbody', {}, jobs.map((job) => h('tr', {
161
- class: 'fx-row-link', tabindex: '0',
162
- onclick: () => openJob(job),
163
- onkeydown: (event) => { if (event.key === 'Enter') openJob(job); },
164
- },
165
- h('td', {}, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
166
- h('td', { class: 'fx-dim' }, job.practitioner ?? '—'),
167
- h('td', {}, job.status ?? NO_STATUS),
168
- h('td', { class: 'fx-dim' }, dateOf(job) ?? '—'),
169
- h('td', { class: 'fx-num fx-strong' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))))));
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)))));
170
593
  }
171
594
 
172
595
  // What a person can do with the job's invoice from here.
@@ -177,22 +600,23 @@ export async function renderJobs(main, ui) {
177
600
  const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
178
601
  const open = h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label);
179
602
  // Only the job changed and the draft is not sent (ledger.js): one tap
180
- // puts the job's new lines on Flow's own draft.
603
+ // puts the job's new lines on Flow's own draft. Nothing goes elsewhere.
181
604
  if (!o.canUseNewLines) return [open];
605
+ const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
182
606
  return [h('button', {
183
607
  class: 'fx-btn fx-btn--primary', type: 'button',
184
608
  onclick: async (event) => {
185
609
  event.currentTarget.disabled = true;
186
610
  try {
187
611
  await flow(`/api/invoicing/jobs/${o.id}/use-new-lines`, { method: 'POST', body: '{}' });
188
- ui.toast('The draft has the new job lines.', 'success');
612
+ ui.toast(`The draft invoice now matches ${system}.`, 'success');
189
613
  } catch (error) {
190
614
  ui.toast(error.message, 'danger');
191
615
  }
192
616
  close();
193
617
  await load();
194
618
  },
195
- }, 'Use new lines'), open];
619
+ }, `Adopt ${system} changes`), open];
196
620
  }
197
621
  // Flow's own stage: a person marks a job ready to invoice, or an
198
622
  // automation does (Settings, Automations).
@@ -236,15 +660,16 @@ export async function renderJobs(main, ui) {
236
660
  // The job's page: a large panel over the board, like a task page. A
237
661
  // breadcrumb, the client's name, the stage, the job's fields, what to fix
238
662
  // and its lines; its activity at the side (below on a phone). Esc, the X
239
- // or a click outside closes it. `atFix` scrolls to what to fix. Nothing is
240
- // edited here yet: Flow only reads the lab system.
663
+ // or a click outside closes it. `atFix` scrolls to what to fix. The job's
664
+ // custom fields can be changed here (in Flow only), and its stage moved
665
+ // (a hand-off to the work system).
241
666
  function openJob(job, { atFix = false } = {}) {
242
667
  const o = job.obligation;
243
668
  const stage = invoiceStage(o);
244
669
  const found = problemsOf(job);
245
670
  const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
246
671
  const status = job.status ?? NO_STATUS;
247
- const late = job.dueOn && job.dueOn < today() && statusRank(job.status) < FINAL_RANK;
672
+ const late = isLate(job);
248
673
  const field = (term, value, attrs = {}) => (value == null || value === '' ? null
249
674
  : h('div', {}, h('dt', {}, term), h('dd', attrs, value)));
250
675
  const link = (attrs, text) => h('a', { class: 'fx-task__link', ...attrs }, text);
@@ -273,12 +698,18 @@ export async function renderJobs(main, ui) {
273
698
  job.dueOn ? field('Ordered', job.orderedOn ? fullDate(job.orderedOn) : null) : null,
274
699
  field('Total', job.totalCents == null ? null : formatCents(job.totalCents)),
275
700
  field('Case number', job.caseRef ?? job.sourceJobRef, { class: 'fx-mono' }),
701
+ job.link?.url ? field(system, link({ href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`)) : null,
276
702
  field(`${system} status`, job.status),
277
703
  field(`${system} client`, job.workClientName && job.workClientName !== job.clientName ? job.workClientName : null),
278
704
  field('Department', job.department),
279
705
  field('Invoice', invoiceText),
280
706
  o?.accountingUrl ? field(state.accountingName ?? 'Accounting', link({ href: o.accountingUrl, target: '_blank', rel: 'noopener' }, `Open in ${state.accountingName ?? 'the accounting system'}`)) : null),
281
707
  fixBox,
708
+ state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
709
+ h('h3', {}, 'Fields'),
710
+ h('dl', { class: 'fx-task__fields' }, state.fields.map((f) => h('div', {}, h('dt', {}, f.name), h('dd', {}, fieldControl(job, f)))))) : null,
711
+ movesStages(job) ? h('section', { class: 'fx-task__custom', 'aria-label': `${system} stage` },
712
+ h('h3', {}, `${system} stage`), stageControl(job)) : null,
282
713
  h('section', { class: 'fx-task__lines' },
283
714
  h('h3', {}, 'Lines'),
284
715
  lines.length ? h('table', {},