@arcforgelabs/flow 2026.10.38 → 2026.10.40

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 (47) hide show
  1. package/README.md +1 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +26 -9
  5. package/server/api/invoicing.js +149 -51
  6. package/server/app.js +5 -2
  7. package/server/core/alerts.js +1 -1
  8. package/server/core/automations.js +1 -1
  9. package/server/core/custom-fields.js +74 -6
  10. package/server/core/ledger.js +29 -7
  11. package/server/core/price-list.js +27 -2
  12. package/server/core/read-before-act.js +3 -2
  13. package/server/core/sending.js +36 -18
  14. package/server/db/doctor.js +18 -19
  15. package/server/db/index.js +74 -3
  16. package/server/db/migrate-all.js +3 -4
  17. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  18. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  19. package/server/db/migrations/meta/_journal.json +7 -0
  20. package/server/db/schema.js +14 -0
  21. package/server/integrations/call-guard.js +205 -15
  22. package/server/integrations/port.js +3 -2
  23. package/server/integrations/schema.js +9 -5
  24. package/server/integrations/seazona/settings.js +8 -4
  25. package/server/integrations/xero/drafts.js +4 -4
  26. package/server/integrations/xero/index.js +5 -1
  27. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  28. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  29. package/server/integrations/xero/mirror.js +4 -3
  30. package/server/integrations/xero/reconcile.js +25 -1
  31. package/server/integrations/xero/routes.js +24 -7
  32. package/server/integrations/xero/settings.js +2 -1
  33. package/server/ui/app.js +42 -6
  34. package/server/ui/custom-fields.js +252 -94
  35. package/server/ui/flow.css +185 -29
  36. package/server/ui/invoice-notes.js +2 -2
  37. package/server/ui/invoicing.js +92 -137
  38. package/server/ui/job-board.js +1 -1
  39. package/server/ui/job-change.js +23 -12
  40. package/server/ui/job-view.js +168 -33
  41. package/server/ui/jobs.js +316 -65
  42. package/server/ui/monitoring.js +13 -12
  43. package/server/ui/price-list.js +43 -86
  44. package/server/ui/price-match.js +93 -0
  45. package/server/ui/price-reconcile.js +357 -0
  46. package/server/ui/reconcile.js +1 -1
  47. package/server/ui/routes.js +1 -1
package/server/ui/jobs.js CHANGED
@@ -19,7 +19,8 @@ import {
19
19
  caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
20
20
  } from '/assets/ui/job-board.js';
21
21
  import {
22
- COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, filterCount, filterJobs, groupJobs, GROUPS, laneOf, normaliseJobsView, NOT_SET, sortJobs, SORTS,
22
+ COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterCount, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
23
+ normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
23
24
  } from '/assets/ui/job-view.js';
24
25
 
25
26
  const LAYOUT_KEY = 'flow_jobs_layout';
@@ -144,13 +145,9 @@ export async function renderJobs(main, ui) {
144
145
  const valueOf = (job, fieldId) => state.values.get(valueKey(job, fieldId)) ?? null;
145
146
  const groupable = () => state.fields.filter((f) => f.groupable);
146
147
  // The Assignee: the lab's first person custom field (Flow makes one,
147
- // server/core/custom-fields.js ensureAssignee). "Assignee: me" keeps the
148
- // jobs it names the person looking at.
148
+ // server/core/custom-fields.js ensureAssignee). The filter "Assignee is
149
+ // Me" keeps the jobs it names the person looking at.
149
150
  const assigneeField = () => state.fields.find((f) => f.type === 'person') ?? null;
150
- const isMine = (job) => {
151
- const field = assigneeField();
152
- return Boolean(field && ui.me?.person?.id && valueOf(job, field.id)?.value === ui.me.person.id);
153
- };
154
151
  // The list's columns a person can choose: Patient only when the
155
152
  // installation keeps patient names; Assignee only with a person field.
156
153
  const builtInColumns = () => Object.keys(COLUMNS).filter((id) => (id !== 'patient' || state.jobs.some((job) => job.patient))
@@ -180,13 +177,18 @@ export async function renderJobs(main, ui) {
180
177
  }, 500);
181
178
  }
182
179
 
183
- // One toolbar control: a button that opens a small menu under it. The menu
184
- // is a popover, so a click outside or Escape closes it.
180
+ // One toolbar control: a chip that opens a small panel under it, and an ×
181
+ // that clears what the chip sets (as ClickUp's "Clear all filters"). The
182
+ // panel is a popover, so a click outside or Escape closes it.
185
183
  let menuCount = 0;
186
- function menu(icon, build) {
184
+ function menu(icon, build, { rows = false, clearLabel = 'Clear' } = {}) {
187
185
  const id = `fx-jobs-menu-${++menuCount}`;
188
- const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
189
- const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' });
186
+ const panel = h('div', { class: `fx-pop${rows ? ' fx-pop--rows' : ''}`, id, popover: 'auto', role: rows ? 'dialog' : 'menu' });
187
+ const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': rows ? 'dialog' : 'menu', 'aria-expanded': 'false' });
188
+ const clear = h('button', { type: 'button', class: 'fx-chip__clear', 'aria-label': clearLabel, title: clearLabel, hidden: true }, lucide(h, 'x'));
189
+ const wrap = h('span', { class: 'fx-chipset' }, button, clear);
190
+ let onClear = null;
191
+ clear.addEventListener('click', () => onClear?.());
190
192
  const fill = () => panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
191
193
  panel.addEventListener('toggle', (event) => {
192
194
  button.setAttribute('aria-expanded', String(event.newState === 'open'));
@@ -197,10 +199,15 @@ export async function renderJobs(main, ui) {
197
199
  panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
198
200
  });
199
201
  return {
200
- node: [button, panel],
201
- set(label, on) {
202
+ node: [wrap, panel],
203
+ wrap,
204
+ fill: () => { if (panel.matches(':popover-open')) fill(); },
205
+ set(label, on, clearIt = null) {
202
206
  button.replaceChildren(svg(icon), h('span', {}, label));
207
+ wrap.classList.toggle('fx-chipset--on', on);
203
208
  button.classList.toggle('fx-chip--on', on);
209
+ onClear = clearIt;
210
+ clear.hidden = !clearIt;
204
211
  },
205
212
  };
206
213
  }
@@ -209,49 +216,217 @@ export async function renderJobs(main, ui) {
209
216
  const option = (text, checked, onclick, role = 'menuitemradio') => h('button', {
210
217
  type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
211
218
  }, h('span', { class: 'fx-pop__tick' }, checked ? svg(TICK_ICON) : null), h('span', {}, text));
212
- const heading = (text) => h('p', { class: 'fx-pop__head' }, text);
213
219
  const DATE_WORDS = ['Oldest first', 'Newest first'];
214
- const DIR_WORDS = { total: ['Low to high', 'High to low'], due: DATE_WORDS, ordered: DATE_WORDS, shipped: DATE_WORDS, board: ['As the lab system has it', 'Reversed'] };
220
+ const NUMBER_WORDS = ['Low to high', 'High to low'];
221
+ const DIR_WORDS = { total: NUMBER_WORDS, due: DATE_WORDS, ordered: DATE_WORDS, shipped: DATE_WORDS };
222
+ const TYPE_WORDS = { date: DATE_WORDS, number: NUMBER_WORDS };
223
+ const dirWords = (by) => DIR_WORDS[by] ?? TYPE_WORDS[fieldById(fieldIdOf(by))?.type] ?? ['A to Z', 'Z to A'];
224
+ const fieldById = (id) => state.fields.find((f) => f.id === id) ?? null;
225
+
226
+ // The parts of a row panel (Group by, Sort, Filters): a title, rows of
227
+ // selects with a bin at the end, and a foot with "Add" and "Clear all".
228
+ const title = (text) => h('p', { class: 'fx-rows__title' }, text);
229
+ const choose = (label, value, options, onchange, extra = {}) => h('select', {
230
+ class: 'fx-select fx-select--sm', 'aria-label': label, onchange: (event) => onchange(event.target.value), ...extra,
231
+ }, options.map((o) => (o.group
232
+ ? (o.options.length ? h('optgroup', { label: o.group }, o.options.map((x) => h('option', { value: x.value, selected: x.value === value }, x.label))) : null)
233
+ : h('option', { value: o.value, selected: o.value === value, disabled: o.disabled }, o.label))));
234
+ const bin = (label, onclick) => h('button', { type: 'button', class: 'fx-rows__bin', 'aria-label': label, title: label, onclick }, lucide(h, 'trash-2'));
235
+ const addButton = (text, onclick) => h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm fx-rows__add', onclick }, lucide(h, 'plus'), text);
236
+ const rowsFoot = (...parts) => h('div', { class: 'fx-rows__foot' }, ...parts);
237
+ const clearAll = (onclick) => h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick }, 'Clear all');
238
+
239
+ // ── Group by ──
240
+ // One row: what to group by, the group order, and a bin that takes the
241
+ // groups away (the list only: the board always has lanes).
242
+ const groupOptions = () => [
243
+ ...Object.entries(GROUPS).filter(([id]) => id !== 'none').map(([value, label]) => ({ value, label })),
244
+ { group: 'Custom fields', options: groupable().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
245
+ ];
246
+ const groupMenu = menu(GROUP_ICON, (redraw) => {
247
+ const list = state.view.layout === 'list';
248
+ const group = state.view.group === 'none' && !list ? 'status' : state.view.group;
249
+ const none = group === 'none';
250
+ return [
251
+ title('Group by'),
252
+ h('div', { class: 'fx-rows' }, h('div', { class: 'fx-rows__row fx-rows__row--group' },
253
+ choose('Group by', none ? '' : group, [...(none ? [{ value: '', label: 'Choose…', disabled: true }] : []), ...groupOptions()], (value) => { change({ group: value }); redraw(); }),
254
+ choose('Group order', state.view.groupDir, [{ value: 'asc', label: 'Ascending' }, { value: 'desc', label: 'Descending' }], (value) => { change({ groupDir: value }); redraw(); }, { disabled: none }),
255
+ list && !none ? bin('Remove the grouping', () => { change({ group: 'none' }); redraw(); }) : h('span', { class: 'fx-rows__gap' }))),
256
+ ];
257
+ }, { rows: true, clearLabel: 'Remove the grouping' });
215
258
 
216
- const groupMenu = menu(GROUP_ICON, (_redraw, close) => [
217
- heading('Group by'),
218
- ...Object.entries(GROUPS).map(([id, text]) => option(text, state.view.group === id, () => { change({ group: id }); close(); })),
219
- groupable().length ? h('div', { class: 'fx-pop__sep' }) : null,
220
- groupable().length ? heading('Custom fields') : null,
221
- ...groupable().map((f) => option(f.name, state.view.group === fieldGroup(f.id), () => { change({ group: fieldGroup(f.id) }); close(); })),
222
- ]);
259
+ // ── Sort ──
260
+ // Rows of sorts, each applied in turn. In the list, a column's head sorts
261
+ // too.
262
+ const sortOptions = () => [
263
+ ...Object.entries(SORTS).map(([value, label]) => ({ value, label })),
264
+ { group: 'Custom fields', options: state.fields.map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
265
+ ];
266
+ const setSorts = (sorts) => change({ sorts });
223
267
  const sortMenu = menu(SORT_ICON, (redraw) => {
224
- const [up, down] = DIR_WORDS[state.view.sort] ?? ['A to Z', 'Z to A'];
268
+ const { sorts } = state.view;
269
+ const unused = sortOptions().flatMap((o) => o.options ?? [o]).filter((o) => !sorts.some((s) => s.by === o.value));
225
270
  return [
226
- heading('Sort by'),
227
- ...Object.entries(SORTS).map(([id, text]) => option(text, state.view.sort === id, () => { change({ sort: id }); redraw(); })),
228
- h('div', { class: 'fx-pop__sep' }),
229
- option(up, state.view.dir === 'asc', () => { change({ dir: 'asc' }); redraw(); }),
230
- option(down, state.view.dir === 'desc', () => { change({ dir: 'desc' }); redraw(); }),
271
+ title('Sort'),
272
+ sorts.length ? h('div', { class: 'fx-rows' }, sorts.map((s, i) => {
273
+ const [up, down] = dirWords(s.by);
274
+ const others = sorts.filter((_, n) => n !== i).map((x) => x.by);
275
+ const options = sortOptions().map((o) => (o.group ? { ...o, options: o.options.filter((x) => !others.includes(x.value)) } : o)).filter((o) => o.group || !others.includes(o.value));
276
+ return h('div', { class: 'fx-rows__row fx-rows__row--sort' },
277
+ h('span', { class: 'fx-rows__lead' }, i ? 'then' : 'Sort by'),
278
+ choose('Sort by', s.by, options, (by) => { setSorts(sorts.map((x, n) => (n === i ? { by, dir: x.dir } : x))); redraw(); }),
279
+ choose('Order', s.dir, [{ value: 'asc', label: up }, { value: 'desc', label: down }], (dir) => { setSorts(sorts.map((x, n) => (n === i ? { ...x, dir } : x))); redraw(); }),
280
+ bin('Remove this sort', () => { setSorts(sorts.filter((_, n) => n !== i)); redraw(); }));
281
+ })) : h('p', { class: 'fx-rows__empty' }, 'No sort. Jobs show in the lab system’s order.'),
282
+ rowsFoot(
283
+ sorts.length < 5 && unused.length ? addButton('Add sort', () => { setSorts([...sorts, { by: unused[0].value, dir: 'asc' }]); redraw(); }) : h('span'),
284
+ sorts.length ? clearAll(() => { setSorts([]); redraw(); }) : null),
231
285
  ];
232
- });
286
+ }, { rows: true, clearLabel: 'Clear the sort' });
287
+
288
+ // ── Filters ──
289
+ // Rows of field, operator and value, with a bin on each, "Add filter",
290
+ // and "Clear all". The second row's And / Or applies to every row.
291
+ const filterFields = () => [
292
+ ...Object.entries(FILTER_FIELDS).filter(([id]) => id !== 'assignee' || assigneeField())
293
+ .map(([value, f]) => ({ value, label: value === 'assignee' ? assigneeField().name : f.label })),
294
+ { group: 'Custom fields', options: customColumns().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
295
+ ];
296
+ const kindOf = (field) => {
297
+ if (field === 'assignee') return assigneeField() ? 'person' : null;
298
+ if (Object.hasOwn(FILTER_FIELDS, field)) return FILTER_FIELDS[field].kind;
299
+ return KIND_OF_TYPE[fieldById(fieldIdOf(field))?.type] ?? null;
300
+ };
301
+ const PROBLEM_OPS = { yes: 'has one', no: 'has none' };
302
+ const opsOf = (field) => (field === 'problem' ? PROBLEM_OPS : OPERATORS[kindOf(field)] ?? {});
303
+ // The choices a choice or person filter offers: [{ value, label }].
304
+ function choicesOf(field) {
305
+ const people = () => [{ value: 'me', label: 'Me' }, ...state.people.map((p) => ({ value: p.id, label: p.name }))];
306
+ const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
307
+ if (field === 'status') return orderStatuses(state.statuses, state.jobs).map((v) => ({ value: v, label: v }));
308
+ if (field === 'stage') return Object.values(STAGE_GROUPS).map((v) => ({ value: v, label: v }));
309
+ if (field === 'client') return labels(state.jobs.map(clientOf));
310
+ if (field === 'practitioner') return labels(state.jobs.map((job) => job.practitioner));
311
+ if (field === 'assignee') return people();
312
+ const custom = fieldById(fieldIdOf(field));
313
+ if (custom?.type === 'person') return people();
314
+ return (custom?.options ?? []).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name }));
315
+ }
316
+ // A job's value for a filter field, as the filter compares it.
317
+ function filterValue(job, field) {
318
+ switch (field) {
319
+ case 'status': return statusOf(job);
320
+ case 'stage': return STAGE_GROUPS[invoiceStage(job.obligation) ?? 'in_work'];
321
+ case 'client': return clientOf(job);
322
+ case 'practitioner': return job.practitioner ?? null;
323
+ case 'assignee': return assigneeField() ? valueOf(job, assigneeField().id)?.value ?? null : null;
324
+ case 'due': return job.dueOn ?? null;
325
+ case 'ordered': return job.orderedOn ?? null;
326
+ case 'shipped': return job.shippedOn ?? null;
327
+ case 'total': return job.totalCents == null ? null : job.totalCents / 100;
328
+ case 'problem': return problemsOf(job).length > 0;
329
+ default: return valueOf(job, fieldIdOf(field))?.value ?? null;
330
+ }
331
+ }
332
+ // A row's value when its operator changes: kept when it still fits.
333
+ function valueFor(field, op, old) {
334
+ const kind = kindOf(field);
335
+ if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return Array.isArray(old) ? old : [];
336
+ if (['before', 'after', 'on'].includes(op)) return typeof old === 'string' && /^\d{4}-/.test(old) ? old : today();
337
+ if (['eq', 'gt', 'lt'].includes(op)) return typeof old === 'number' ? old : null;
338
+ if (['has', 'is'].includes(op)) return typeof old === 'string' ? old : '';
339
+ return null;
340
+ }
341
+ const setFilters = (filters) => change({ filters });
342
+ const setRow = (i, patch) => setFilters(state.view.filters.map((f, n) => (n === i ? { ...f, ...patch } : f)));
343
+ // A choice row's value: a button that opens a list of choices to tick,
344
+ // with a search when the list is long.
345
+ function choiceValue(i, filter) {
346
+ const choices = choicesOf(filter.field);
347
+ const named = () => {
348
+ const on = choices.filter((c) => filter.value.includes(c.value)).map((c) => c.label);
349
+ return on.length ? on.join(', ') : 'Choose…';
350
+ };
351
+ const id = `fx-jobs-choice-${i}`;
352
+ const button = h('button', { type: 'button', class: 'fx-select fx-select--sm fx-rows__value', popovertarget: id, title: named() }, named());
353
+ const pop = h('div', { class: 'fx-pop fx-pop--choices', id, popover: 'auto', role: 'menu' });
354
+ const find = choices.length > 8 ? h('input', { class: 'fx-input fx-input--sm', type: 'search', placeholder: 'Find', 'aria-label': 'Find a choice' }) : null;
355
+ const items = h('div', {});
356
+ const drawItems = () => {
357
+ const q = find?.value.trim().toLowerCase() ?? '';
358
+ items.replaceChildren(...choices.filter((c) => !q || c.label.toLowerCase().includes(q)).map((c) => option(c.label, filter.value.includes(c.value), () => {
359
+ filter = { ...filter, value: filter.value.includes(c.value) ? filter.value.filter((v) => v !== c.value) : [...filter.value, c.value] };
360
+ setRow(i, { value: filter.value });
361
+ button.textContent = named();
362
+ button.title = named();
363
+ drawItems();
364
+ }, 'menuitemcheckbox')));
365
+ };
366
+ find?.addEventListener('input', drawItems);
367
+ pop.append(...[find, items].filter(Boolean));
368
+ pop.addEventListener('toggle', (event) => {
369
+ if (event.newState !== 'open') return;
370
+ drawItems();
371
+ const at = button.getBoundingClientRect();
372
+ pop.style.top = `${Math.round(at.bottom + 4)}px`;
373
+ pop.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - pop.offsetWidth - 8)))}px`;
374
+ find?.focus();
375
+ });
376
+ return [button, pop];
377
+ }
378
+ function valueControl(i, filter) {
379
+ const kind = kindOf(filter.field);
380
+ const { op } = filter;
381
+ if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return choiceValue(i, filter);
382
+ if (['before', 'after', 'on'].includes(op)) {
383
+ return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'date', 'aria-label': 'Date', value: filter.value ?? '', onchange: (event) => setRow(i, { value: event.target.value || null }) });
384
+ }
385
+ if (['eq', 'gt', 'lt'].includes(op)) {
386
+ return h('input', {
387
+ class: 'fx-input fx-input--sm fx-rows__value', type: 'number', step: 'any', inputmode: 'decimal', 'aria-label': 'Number', value: filter.value ?? '',
388
+ oninput: (event) => setRow(i, { value: event.target.value === '' ? null : Number(event.target.value) }),
389
+ });
390
+ }
391
+ if (['has', 'is'].includes(op)) {
392
+ return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'text', 'aria-label': 'Text', value: filter.value ?? '', oninput: (event) => setRow(i, { value: event.target.value }) });
393
+ }
394
+ return h('span', { class: 'fx-rows__value fx-rows__value--none' });
395
+ }
233
396
  const filterMenu = menu(FILTER_ICON, (redraw) => {
234
- const hidden = new Set(state.view.hide);
397
+ const { filters, match } = state.view;
398
+ const fields = filterFields();
235
399
  return [
236
- heading('Show only'),
237
- option('Late jobs', state.view.late, () => { change({ late: !state.view.late }); redraw(); }, 'menuitemcheckbox'),
238
- option('Jobs with a problem', state.view.problems, () => { change({ problems: !state.view.problems }); redraw(); }, 'menuitemcheckbox'),
239
- assigneeField() ? option(`${assigneeField().name}: me`, state.view.mine, () => { change({ mine: !state.view.mine }); redraw(); }, 'menuitemcheckbox') : null,
240
- h('div', { class: 'fx-pop__sep' }),
241
- heading('Statuses'),
242
- ...orderStatuses(state.statuses, state.jobs).map((label) => option(label, !hidden.has(label), () => {
243
- const next = new Set(hidden);
244
- if (next.has(label)) next.delete(label); else next.add(label);
245
- change({ hide: [...next] });
246
- redraw();
247
- }, 'menuitemcheckbox')),
248
- filterCount(state.view) ? h('div', { class: 'fx-pop__foot' }, h('button', {
249
- type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick: () => { change({ hide: [], late: false, problems: false, mine: false }); redraw(); },
250
- }, 'Clear filters')) : null,
400
+ title('Filters'),
401
+ filters.length ? h('div', { class: 'fx-rows' }, filters.map((filter, i) => {
402
+ const known = kindOf(filter.field);
403
+ const lead = i === 0 ? h('span', { class: 'fx-rows__lead' }, 'Where')
404
+ : i === 1 ? choose('Match', match, [{ value: 'all', label: 'And' }, { value: 'any', label: 'Or' }], (value) => { change({ match: value }); redraw(); })
405
+ : h('span', { class: 'fx-rows__lead' }, match === 'any' ? 'Or' : 'And');
406
+ if (!known) {
407
+ return h('div', { class: 'fx-rows__row' }, lead, h('span', { class: 'fx-rows__gone' }, 'A custom field that was deleted'), bin('Remove this filter', () => { setFilters(filters.filter((_, n) => n !== i)); redraw(); }));
408
+ }
409
+ const ops = opsOf(filter.field);
410
+ return h('div', { class: 'fx-rows__row' },
411
+ lead,
412
+ choose('Field', filter.field, fields, (field) => {
413
+ const op = Object.keys(opsOf(field))[0];
414
+ setRow(i, { field, op, value: valueFor(field, op, null) });
415
+ redraw();
416
+ }),
417
+ choose('Operator', filter.op, Object.entries(ops).map(([value, label]) => ({ value, label })), (op) => { setRow(i, { op, value: valueFor(filter.field, op, filter.value) }); redraw(); }),
418
+ valueControl(i, filter),
419
+ bin('Remove this filter', () => { setFilters(filters.filter((_, n) => n !== i)); redraw(); }));
420
+ })) : h('p', { class: 'fx-rows__empty' }, 'No filters. Every job shows.'),
421
+ rowsFoot(
422
+ filters.length < 20 ? addButton('Add filter', () => { setFilters([...filters, { field: 'status', op: 'is', value: [] }]); redraw(); }) : h('span'),
423
+ filters.length ? clearAll(() => { setFilters([]); redraw(); }) : null),
251
424
  ];
252
- });
425
+ }, { rows: true, clearLabel: 'Clear all filters' });
426
+
253
427
  // Columns (the list only): Client always shows; each other column can be
254
428
  // shown or hidden. Custom fields are columns too.
429
+ const heading = (text) => h('p', { class: 'fx-pop__head' }, text);
255
430
  const columnsMenu = menu(COLUMNS_ICON, (redraw) => {
256
431
  const on = new Set(state.view.columns);
257
432
  const flip = (id) => {
@@ -271,18 +446,27 @@ export async function renderJobs(main, ui) {
271
446
  });
272
447
  controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node, ...columnsMenu.node);
273
448
 
449
+ // A sort's name: a built-in key or a custom field.
450
+ const sortName = (by) => SORTS[by] ?? fieldById(fieldIdOf(by))?.name ?? 'Custom field';
274
451
  function drawControls() {
275
- const { group, sort, dir } = state.view;
452
+ const { group, sorts, layout } = state.view;
453
+ const list = layout === 'list';
276
454
  const field = fieldOfGroup(group);
277
455
  // The board has no "None": it shows the status lanes.
278
- const shown = group === 'none' && state.view.layout === 'board' ? 'status' : group;
279
- groupMenu.set(`Group: ${field?.name ?? GROUPS[shown] ?? GROUPS.status}`, shown !== DEFAULT_VIEW.group && (Boolean(field) || Boolean(GROUPS[shown])));
280
- const sorted = sort !== DEFAULT_VIEW.sort || dir !== DEFAULT_VIEW.dir;
281
- sortMenu.set(sorted ? `Sort: ${SORTS[sort]}${dir === 'desc' ? ' ↓' : ''}` : 'Sort', sorted);
456
+ const shown = group === 'none' && !list ? 'status' : group;
457
+ const label = shown === 'none' ? 'Group: None' : `Group: ${field?.name ?? GROUPS[shown] ?? GROUPS.status}`;
458
+ const grouped = list ? shown !== 'none' : shown !== 'status';
459
+ groupMenu.set(label, shown !== DEFAULT_VIEW.group, grouped ? () => change({ group: list ? 'none' : 'status' }) : null);
460
+ // In the list, the column heads sort; the Sort chip shows there only
461
+ // while a sort is on, so it can be seen and cleared.
462
+ const [first] = sorts;
463
+ sortMenu.set(sorts.length > 1 ? `Sort · ${sorts.length}` : first ? `Sort: ${sortName(first.by)} ${first.dir === 'desc' ? '↓' : '↑'}` : 'Sort', sorts.length > 0,
464
+ sorts.length ? () => change({ sorts: [] }) : null);
465
+ sortMenu.wrap.hidden = list && !sorts.length;
282
466
  const count = filterCount(state.view);
283
- filterMenu.set(count ? `Filter · ${count}` : 'Filter', count > 0);
467
+ filterMenu.set(count ? `${count} ${count === 1 ? 'Filter' : 'Filters'}` : 'Filter', count > 0, state.view.filters.length ? () => change({ filters: [], match: 'all' }) : null);
284
468
  columnsMenu.set('Columns', false);
285
- columnsMenu.node[0].hidden = state.view.layout !== 'list';
469
+ columnsMenu.wrap.hidden = !list;
286
470
  }
287
471
  drawControls();
288
472
 
@@ -295,8 +479,8 @@ export async function renderJobs(main, ui) {
295
479
  return;
296
480
  }
297
481
  const found = filterJobs(state.jobs.filter((job) => matches(job, state.q)), state.view,
298
- { status: statusOf, late: isLate, problem: (job) => problemsOf(job).length > 0, mine: isMine });
299
- const groups = grouped(sortJobs(found, state.view));
482
+ { valueOf: filterValue, kindOf, late: isLate, me: ui.me?.person?.id ?? null, today: today() });
483
+ const groups = grouped(sortJobs(found, state.view, sortKey));
300
484
  main.classList.toggle('fx-main--board', state.view.layout === 'board');
301
485
  // A tick on a job the search or filter now hides goes.
302
486
  const shown = new Set(found.map(jobKey));
@@ -379,7 +563,12 @@ export async function renderJobs(main, ui) {
379
563
  // A group's `drop` says what a card dropped in it changes: a custom
380
564
  // field's value, or the job's stage in the work system. No `drop`: cards
381
565
  // cannot be dropped there (invoice stage, client, practitioner).
566
+ // "Descending" shows the groups in the other order.
382
567
  function grouped(jobs) {
568
+ const groups = groupsOf(jobs);
569
+ return state.view.groupDir === 'desc' ? groups.reverse() : groups;
570
+ }
571
+ function groupsOf(jobs) {
383
572
  const { group } = state.view;
384
573
  const field = fieldOfGroup(group);
385
574
  if (field) {
@@ -393,8 +582,7 @@ export async function renderJobs(main, ui) {
393
582
  }
394
583
  if (group === 'none' && state.view.layout === 'list') return [{ label: null, jobs, rank: 'plain' }];
395
584
  if (group === 'status' || group === 'none' || !GROUPS[group]) {
396
- const hidden = new Set(state.view.hide);
397
- return groupJobs(jobs, orderStatuses(state.statuses, state.jobs).filter((label) => !hidden.has(label)), laneStatusOf)
585
+ return groupJobs(jobs, keptStatuses(state.view, orderStatuses(state.statuses, state.jobs)), laneStatusOf)
398
586
  .map((g) => ({ ...g, rank: statusRank(g.label), drop: g.label === NO_STATUS ? null : { kind: 'stage', status: g.label } }));
399
587
  }
400
588
  if (group === 'stage') {
@@ -656,7 +844,65 @@ export async function renderJobs(main, ui) {
656
844
  // A column's menu (a click on its head, or a right-click): sort by it,
657
845
  // move it, hide it. Client always shows. `column` null is the + menu: the
658
846
  // hidden columns, to add one back.
659
- const SORT_OF = { practitioner: 'practitioner', due: 'due', ordered: 'ordered', shipped: 'shipped', total: 'total' };
847
+ // What a column's head sorts by: a built-in key, or the column's custom
848
+ // field (Assignee is the lab's person field).
849
+ const SORT_OF = { client: 'client', practitioner: 'practitioner', due: 'due', ordered: 'ordered', shipped: 'shipped', total: 'total' };
850
+ const sortOf = (columnId) => SORT_OF[columnId]
851
+ ?? (columnId === 'assignee' && assigneeField() ? fieldGroup(assigneeField().id) : fieldIdOf(columnId) ? columnId : null);
852
+ // A custom field's sort key: a person's name, an option's place in the
853
+ // list, ticked first or last, or the value.
854
+ function sortKey(job, by) {
855
+ const field = fieldById(fieldIdOf(by));
856
+ const value = field ? valueOf(job, field.id)?.value ?? null : null;
857
+ if (!field || value == null) return null;
858
+ if (field.type === 'person') return (state.people.find((p) => p.id === value)?.name ?? '').toLowerCase() || null;
859
+ if (field.type === 'dropdown') {
860
+ const at = (field.options ?? []).findIndex((o) => o.id === value);
861
+ return at < 0 ? null : at;
862
+ }
863
+ if (field.type === 'checkbox') return value === true ? 1 : 0;
864
+ return typeof value === 'string' ? value.toLowerCase() : value;
865
+ }
866
+ // A column head's sort button, as ClickUp's: it shows on hover, or
867
+ // always while it sorts. A click sorts one way, again the other way, and
868
+ // a third time takes the sort off. A right-click on a sorting head opens
869
+ // a small menu: change the direction, or clear the sort.
870
+ function sortButton(columnId, label) {
871
+ const by = sortOf(columnId);
872
+ if (!by) return null;
873
+ const { sorts } = state.view;
874
+ const at = sorts.findIndex((s) => s.by === by);
875
+ const on = at >= 0 ? sorts[at] : null;
876
+ const [up, down] = dirWords(by);
877
+ const next = () => {
878
+ if (!on) change({ sorts: [{ by, dir: 'asc' }] });
879
+ else if (on.dir === 'asc') change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: 'desc' } : s)) });
880
+ else change({ sorts: sorts.filter((_, n) => n !== at) });
881
+ };
882
+ const words = on ? `Sorted ${on.dir === 'asc' ? up : down}. Click to ${on.dir === 'asc' ? `sort ${down}` : 'clear the sort'}` : `Sort by ${label}`;
883
+ return h('button', {
884
+ type: 'button', class: `fx-th-sort${on ? ' fx-th-sort--on' : ''}`, 'aria-label': words, title: words,
885
+ onclick: (event) => { event.stopPropagation(); next(); },
886
+ oncontextmenu: (event) => {
887
+ if (!on) return;
888
+ event.preventDefault();
889
+ event.stopPropagation();
890
+ onRightButton(event, () => sortMenuAt(event, by, label, on, at));
891
+ },
892
+ }, lucide(h, on ? (on.dir === 'asc' ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'),
893
+ on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
894
+ }
895
+ function sortMenuAt(event, by, label, on, at) {
896
+ const close = () => columnPop.hidePopover?.();
897
+ const { sorts } = state.view;
898
+ const flip = () => change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)) });
899
+ columnPop.replaceChildren(
900
+ h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { close(); flip(); } },
901
+ h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${label}`), h('span', { class: 'fx-pop__end' }, svg(TICK_ICON))),
902
+ h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ sorts: sorts.filter((_, n) => n !== at) }); } },
903
+ h('span', { class: 'fx-pop__tick' }, lucide(h, 'ban')), h('span', {}, 'Clear sort')));
904
+ placeColumnPop(event);
905
+ }
660
906
  const columnPop = h('div', { class: 'fx-pop', id: 'fx-jobs-colmenu', popover: 'auto', role: 'menu' });
661
907
  document.body.append(columnPop);
662
908
  // A popover opened while the right button is still down closes again on
@@ -672,11 +918,11 @@ export async function renderJobs(main, ui) {
672
918
  h('span', { class: 'fx-pop__tick' }, lucide(h, icon)), h('span', {}, text));
673
919
  const parts = [];
674
920
  if (column) {
675
- const sort = SORT_OF[column.id];
921
+ const sort = sortOf(column.id);
676
922
  if (sort) {
677
- const [up, down] = DIR_WORDS[sort] ?? ['A to Z', 'Z to A'];
678
- parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sort, dir: 'asc' })),
679
- item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sort, dir: 'desc' })), h('div', { class: 'fx-pop__sep' }));
923
+ const [up, down] = dirWords(sort);
924
+ parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sorts: [{ by: sort, dir: 'asc' }] })),
925
+ item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sorts: [{ by: sort, dir: 'desc' }] })), h('div', { class: 'fx-pop__sep' }));
680
926
  }
681
927
  const rest = ids.filter((c) => c !== column.id);
682
928
  parts.push(item('arrow-left-to-line', 'Move to start', () => change({ columns: [column.id, ...rest] })),
@@ -697,6 +943,9 @@ export async function renderJobs(main, ui) {
697
943
  if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
698
944
  }
699
945
  columnPop.replaceChildren(...parts);
946
+ placeColumnPop(event);
947
+ }
948
+ function placeColumnPop(event) {
700
949
  columnPop.showPopover?.();
701
950
  const x = event.clientX || event.currentTarget?.getBoundingClientRect?.().left || 0;
702
951
  const y = event.clientY || event.currentTarget?.getBoundingClientRect?.().bottom || 0;
@@ -809,9 +1058,11 @@ export async function renderJobs(main, ui) {
809
1058
  return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
810
1059
  h('thead', {}, h('tr', {},
811
1060
  h('th', { class: 'fx-tick-cell' }, setBox(allJobs, 'Tick every job shown')),
812
- h('th', {}, 'Client'),
1061
+ h('th', {}, h('span', { class: 'fx-th-cell' }, h('span', { class: 'fx-th fx-th--fixed' }, 'Client'), sortButton('client', 'Client'))),
813
1062
  columns.map((c) => h('th', { class: c.num ? 'fx-num' : null, oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => columnMenu(c, event)); } },
814
- h('button', { type: 'button', class: 'fx-th', 'aria-haspopup': 'menu', title: 'Column menu (or right-click)', onclick: (event) => columnMenu(c, event) }, c.label))),
1063
+ h('span', { class: 'fx-th-cell' },
1064
+ h('button', { type: 'button', class: 'fx-th', 'aria-haspopup': 'menu', title: 'Column menu (or right-click)', onclick: (event) => columnMenu(c, event) }, c.label),
1065
+ sortButton(c.id, c.label)))),
815
1066
  h('th', { class: 'fx-th-add' }, h('button', {
816
1067
  type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => columnMenu(null, event),
817
1068
  }, '+')))),
@@ -17,9 +17,10 @@ function loadScript({ src, integrity }) {
17
17
  });
18
18
  }
19
19
 
20
- // "Notify me": the form's email field (Sentry's own words) starts empty and
21
- // is optional. A person who types an address there asks to hear back; Flow
22
- // tags the report notify=yes, so the support desk sees it on the task.
20
+ // "Notify me": the form's email field (Sentry's own words) starts with the
21
+ // signed-in person's email. A report sent with an address asks to hear back;
22
+ // Flow tags it notify=yes, so the support desk sees it on the task. A person
23
+ // who clears the field sends notify=no.
23
24
  // Sentry's form has no tick box of its own; this keeps to its stock options.
24
25
  export function tagNotify(event) {
25
26
  if (event?.type !== 'feedback') return;
@@ -71,9 +72,8 @@ export async function startMonitoring({ flow, person }) {
71
72
  showBranding: false,
72
73
  // Flow already knows who is signed in (setUser below).
73
74
  showName: false,
74
- // The email field is the opt-in (tagNotify), so it is not filled from
75
- // the signed-in person: a key setUser never sets.
76
- useSentryUser: { name: 'username', email: 'notify_email' },
75
+ // The form fills in the signed-in person's name and email (setUser).
76
+ useSentryUser: { name: 'username', email: 'email' },
77
77
  enableScreenshot: true,
78
78
  triggerLabel: 'Report a problem',
79
79
  formTitle: 'Report a problem',
@@ -102,16 +102,17 @@ export async function startMonitoring({ flow, person }) {
102
102
  });
103
103
  Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
104
104
  // Who reported or met the error, so the lab can follow up.
105
- Sentry.setUser({ id: person.id, email: person.email });
105
+ Sentry.setUser({ id: person.id, email: person.email, username: person.name || undefined });
106
106
  const feedback = config.feedback ? Sentry.getFeedback() : null;
107
107
  if (!feedback) return null;
108
- // One form, made on first use and opened again after that.
108
+ // A new form each time. Sentry's form keeps a refused screenshot's error
109
+ // ("Permission denied by user") until the next click, so a reused form
110
+ // would show it before the person tries again.
109
111
  let form = null;
110
112
  return async () => {
111
- if (!form) {
112
- form = await feedback.createForm();
113
- form.appendToDom();
114
- }
113
+ form?.removeFromDom();
114
+ form = await feedback.createForm();
115
+ form.appendToDom();
115
116
  form.open();
116
117
  };
117
118
  }