@arcforgelabs/flow 2026.10.51 → 2026.10.53-1

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 (43) hide show
  1. package/npm-shrinkwrap.json +2 -2
  2. package/package.json +1 -1
  3. package/server/api/captures.js +26 -4
  4. package/server/api/invoicing.js +1 -1
  5. package/server/app.js +2 -0
  6. package/server/auth/people.js +3 -3
  7. package/server/core/ai.js +2 -1
  8. package/server/core/capture-match.js +158 -0
  9. package/server/core/captures.js +141 -7
  10. package/server/core/invoice-rules.js +16 -5
  11. package/server/core/reader.js +1 -10
  12. package/server/core/sending.js +7 -3
  13. package/server/db/migrations/0024_rule_icon.sql +5 -0
  14. package/server/db/migrations/meta/0024_snapshot.json +4703 -0
  15. package/server/db/migrations/meta/_journal.json +8 -1
  16. package/server/db/schema.js +3 -0
  17. package/server/integrations/seazona/history/backfill.js +39 -9
  18. package/server/integrations/seazona/index.js +1 -12
  19. package/server/integrations/seazona/lanes.js +44 -27
  20. package/server/integrations/seazona/stage-moves.js +3 -13
  21. package/server/integrations/sentry/index.js +6 -6
  22. package/server/integrations/setup.js +1 -0
  23. package/server/integrations/xero/drafts.js +27 -13
  24. package/server/ui/app-page.js +1 -1
  25. package/server/ui/app.js +61 -20
  26. package/server/ui/capture-fields.js +17 -0
  27. package/server/ui/captures.js +289 -0
  28. package/server/ui/columns.js +68 -3
  29. package/server/ui/custom-fields.js +3 -9
  30. package/server/ui/flow.css +94 -18
  31. package/server/ui/invoice-rules.js +10 -2
  32. package/server/ui/invoicing-settings.js +2 -8
  33. package/server/ui/invoicing.js +12 -8
  34. package/server/ui/job-view.js +6 -3
  35. package/server/ui/jobs.js +40 -117
  36. package/server/ui/kit.js +86 -1
  37. package/server/ui/list-views.js +47 -3
  38. package/server/ui/mobile.js +45 -5
  39. package/server/ui/nav-pins.js +4 -2
  40. package/server/ui/price-list.js +138 -69
  41. package/server/ui/routes.js +4 -1
  42. package/server/ui/saved-views.js +2 -0
  43. package/server/ui/view-bar.js +61 -2
package/server/ui/app.js CHANGED
@@ -10,6 +10,7 @@ import { getAppearance, onAppearanceChange, setAppearance, loadFonts } from '/as
10
10
  import { flowClerkAppearance } from '/assets/ui/clerk-appearance.js';
11
11
  import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/assets/theme/themes.js';
12
12
  import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
13
+ import { renderCaptures } from '/assets/ui/captures.js';
13
14
  import { renderContacts } from '/assets/ui/contacts.js';
14
15
  import { automationSection, renderAutomations } from '/assets/ui/automations.js';
15
16
  import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
@@ -25,7 +26,7 @@ import { isSearchKey, openSearch } from '/assets/ui/search.js';
25
26
  import { alertStrip } from '/assets/ui/alerts.js';
26
27
  import { initialsOf, personLabel } from '/assets/ui/person.js';
27
28
  import { startTips } from '/assets/ui/tip.js';
28
- import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
29
+ import { crumbs, dialog, edgeFade, fieldError, h, holdButton, iconPicker, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
29
30
  import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
30
31
 
31
32
  const $ = (id) => document.getElementById(id);
@@ -152,6 +153,7 @@ let me = null;
152
153
  // cannot be unpinned (nav-pins.js).
153
154
  const VIEWS = {
154
155
  jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
156
+ captures: { label: 'Capture', icon: 'camera', render: (main) => renderCaptures(main, ui()) },
155
157
  invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
156
158
  contacts: { label: 'Contacts', icon: 'contacts', render: (main, id) => renderContacts(main, ui(), id) },
157
159
  reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
@@ -174,6 +176,7 @@ const ICON_PATHS = {
174
176
  jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
175
177
  invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
176
178
  contacts: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M6 16c.4-1.6 1.5-2.4 3-2.4s2.6.8 3 2.4M14 9h5M14 13h4"/>',
179
+ camera: '<path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/>',
177
180
  bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
178
181
  rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
179
182
  reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
@@ -332,7 +335,7 @@ function navigate(event) {
332
335
  // The shared pieces a view module (invoicing.js) builds with. Its `go`
333
336
  // takes a path, or a link's click event.
334
337
  const ui = () => ({
335
- h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, me, holdButton, tileCard, tileSection, crumbs,
338
+ h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
336
339
  go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
337
340
  });
338
341
 
@@ -741,6 +744,9 @@ async function renderIntegration(main, id) {
741
744
  // Settings: closed on a phone, open on a wider screen, then as the person
742
745
  // leaves it while the page redraws (Connect).
743
746
  let settingsOpen = !window.matchMedia?.('(max-width: 639px)').matches;
747
+ // Advanced: the technical settings and the host's values. Open while the
748
+ // integration is off, so Connect shows every setting it needs.
749
+ let advancedOpen = null;
744
750
  const alerts = alertStrip(ui(), 'integrations', {
745
751
  scope: id,
746
752
  onLoad: ({ danger }) => {
@@ -753,10 +759,11 @@ async function renderIntegration(main, id) {
753
759
  shown = current;
754
760
  const inputs = new Map();
755
761
  const rows = [];
762
+ const advanced = [];
756
763
  for (const field of current.fields) {
757
764
  if (field.source === 'host') {
758
765
  // The host sets it: a value to read, not a box to type in.
759
- rows.push(settingRow({
766
+ advanced.push(settingRow({
760
767
  label: field.label,
761
768
  help: 'Set by whoever hosts Flow.',
762
769
  control: field.secret ? h('span', { class: 'fx-dim' }, 'Set') : h('code', { class: 'fx-setting__value' }, field.value),
@@ -778,7 +785,7 @@ async function renderIntegration(main, id) {
778
785
  }
779
786
  inputs.set(field.name, { field, input });
780
787
  const suggestion = field.suggest === 'callback' ? `${location.origin}/api/${current.id}/oauth/callback` : null;
781
- rows.push(settingRow({
788
+ (field.advanced ? advanced : rows).push(settingRow({
782
789
  label: field.label,
783
790
  hint: field.unreadable ? 'Stored with another key. Enter it again.' : null,
784
791
  help: field.help,
@@ -875,9 +882,12 @@ async function renderIntegration(main, id) {
875
882
  h('summary', { class: 'fx-int-section__title' }, 'Set up'),
876
883
  h('ol', { class: 'fx-steps' }, guide.steps.map((step) => h('li', {}, step))),
877
884
  guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' }, `${current.name} site ↗`) : null) : null,
878
- rows.length ? h('details', { class: 'fx-int-section fx-int-fold', open: settingsOpen, ontoggle: (event) => { settingsOpen = event.currentTarget.open; } },
885
+ rows.length || advanced.length ? h('details', { class: 'fx-int-section fx-int-fold', open: settingsOpen, ontoggle: (event) => { settingsOpen = event.currentTarget.open; } },
879
886
  h('summary', { class: 'fx-int-section__title' }, 'Settings'),
880
- h('div', { class: 'fx-settings' }, rows),
887
+ rows.length ? h('div', { class: 'fx-settings' }, rows) : null,
888
+ advanced.length ? h('details', { class: 'fx-int-fold fx-int-fold--sub', open: advancedOpen ?? !on, ontoggle: (event) => { advancedOpen = event.currentTarget.open === !on ? null : event.currentTarget.open; } },
889
+ h('summary', {}, 'Advanced'),
890
+ h('div', { class: 'fx-settings' }, advanced)) : null,
881
891
  canChange && on && inputs.size ? h('div', { class: 'fx-row fx-row--end' }, h('button', { class: 'fx-btn', type: 'button', onclick: () => save(undefined) }, 'Save changes')) : null) : null,
882
892
  ].filter(Boolean)),
883
893
  h('aside', { class: 'fx-int-page__meta' },
@@ -980,21 +990,26 @@ function xeroImportSection(organisations) {
980
990
  }
981
991
 
982
992
  // Seazona's history read (docs/SEAZONA.md, "The API backfill"): past orders
983
- // from a start day, a few calls a run inside the rate limit. Start and Stop
984
- // call no Seazona; the background lane reads. While it runs, the row reads
985
- // its progress again every 30 seconds, from Flow only.
993
+ // from a start day, a few calls a run inside the rate limit. Start, Resume
994
+ // and Stop call no Seazona; the background lane reads, and skips orders Flow
995
+ // read before. While it runs, the card reads its progress again every 5
996
+ // seconds, from Flow only, and counts down to the lane's next run.
986
997
  function seazonaHistorySection() {
987
998
  const canAct = ['owner', 'admin'].includes(me?.person?.role);
988
999
  const yearAgo = new Date(Date.now() - 365 * 86_400_000).toISOString().slice(0, 10);
989
- const from = h('input', { class: 'fx-input', type: 'date', 'aria-label': 'Read from', value: yearAgo, disabled: !canAct });
990
- const status = h('span', {}, 'Checking…');
991
- const jobs = h('span', { class: 'fx-num' }, '…');
1000
+ const from = h('input', { class: 'fx-input fx-history__day', type: 'date', 'aria-label': 'Read from', value: yearAgo, disabled: !canAct });
1001
+ const status = h('span', { class: 'fx-history__status' }, 'Checking…');
992
1002
  const control = h('span', { class: 'fx-row' });
1003
+ const bar = h('progress', { class: 'fx-history__bar', max: 1, value: 0, 'aria-label': 'Read so far' });
1004
+ const barRow = settingRow({ label: 'Progress', hint: h('span', {}), control: bar, wide: true });
1005
+ barRow.hidden = true;
993
1006
  const day = (value) => new Date(`${value}T00:00:00Z`).toLocaleDateString('en-AU', { dateStyle: 'medium', timeZone: 'UTC' });
994
1007
  const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
995
1008
  h('h2', { class: 'fx-set__title' }, 'History'),
996
- settingRow({ label: 'Read from', hint: status, help: 'Reads past Seazona orders into Flow, 10 every 2 minutes. Each job waits on hold and never drafts by itself.', control }),
997
- settingRow({ label: 'Jobs read', control: jobs }));
1009
+ settingRow({ label: 'Read from', hint: status, help: 'Reads past Seazona orders into Flow, 10 every 2 minutes. Orders Flow read before are skipped. Each job waits on hold and never drafts by itself.', control }),
1010
+ barRow);
1011
+ let timer = null;
1012
+ let tick = null;
998
1013
  const press = async (button, path, body) => {
999
1014
  button.disabled = true;
1000
1015
  try {
@@ -1006,7 +1021,14 @@ function seazonaHistorySection() {
1006
1021
  }
1007
1022
  load();
1008
1023
  };
1024
+ const start = (label, body, primary = true) => h('button', {
1025
+ class: `fx-btn fx-btn--sm${primary ? ' fx-btn--primary' : ''}`, type: 'button', disabled: !canAct,
1026
+ onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill', body()),
1027
+ }, label);
1009
1028
  const load = async () => {
1029
+ clearTimeout(timer);
1030
+ clearInterval(tick);
1031
+ if (!section.isConnected && timer !== null) return;
1010
1032
  let body;
1011
1033
  try {
1012
1034
  body = await flow('/api/seazona/history');
@@ -1015,15 +1037,34 @@ function seazonaHistorySection() {
1015
1037
  return;
1016
1038
  }
1017
1039
  const run = body.backfill;
1018
- jobs.replaceChildren(String(body.history?.jobs ?? 0));
1040
+ barRow.hidden = !run;
1041
+ if (run) {
1042
+ const span = Date.parse(run.through) - Date.parse(run.from) + 86_400_000;
1043
+ const at = run.running ? run.readingDay ?? run.through : run.resumeFrom ?? run.through;
1044
+ bar.value = run.running || run.resumeFrom ? Math.min(1, Math.max(0, (Date.parse(at) - Date.parse(run.from)) / span)) : 1;
1045
+ barRow.querySelector('.fx-setting__hint').replaceChildren(`${day(run.from)} to ${day(run.through)}`);
1046
+ }
1019
1047
  if (run?.running) {
1020
- status.replaceChildren(`Reading ${day(run.nextDay ?? run.from)} · ${run.ordersRead} read`);
1048
+ const what = { waiting: 'Waiting', failed: 'Failed' }[run.phase] ?? `Reading ${day(run.readingDay ?? run.through)}`;
1049
+ const reading = `${what} · ${run.ordersRead} read${run.waitingToRead ? ` · ${run.waitingToRead} to read` : ''}`;
1050
+ const next = h('span', { class: 'fx-dim' });
1051
+ const count = () => {
1052
+ const left = Math.round((Date.parse(run.nextRunAt) - Date.now()) / 1000);
1053
+ next.replaceChildren(left > 0 ? `· ${run.phase === 'running' ? 'next' : 'retry'} in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}` : '· reading now');
1054
+ };
1055
+ if (run.nextRunAt && run.phase !== 'failed') { count(); tick = setInterval(count, 1000); }
1056
+ status.replaceChildren(
1057
+ run.phase === 'running' ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : '',
1058
+ reading, next, run.cause ? h('span', { class: 'fx-dim' }, `· ${run.cause}`) : '');
1021
1059
  control.replaceChildren(h('button', { class: 'fx-btn fx-btn--sm', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill/stop') }, 'Stop'));
1022
- setTimeout(() => { if (section.isConnected) load(); }, 30_000);
1023
- return;
1060
+ } else if (run?.resumeFrom) {
1061
+ status.replaceChildren(`Stopped at ${day(run.resumeFrom)} · ${run.ordersRead} read`);
1062
+ control.replaceChildren(from, start('Start', () => ({ from: from.value }), false), start('Resume', () => ({ from: run.resumeFrom, through: run.through })));
1063
+ } else {
1064
+ status.replaceChildren(run ? `Done · ${run.ordersRead} read` : 'Not started');
1065
+ control.replaceChildren(from, start('Start', () => ({ from: from.value })));
1024
1066
  }
1025
- status.replaceChildren(run ? `${run.stopped ? 'Stopped' : 'Done'} · ${run.ordersRead} read from ${day(run.from)}` : 'Not started');
1026
- control.replaceChildren(from, h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', disabled: !canAct, onclick: (event) => press(event.currentTarget, '/api/seazona/history/backfill', { from: from.value }) }, 'Start'));
1067
+ timer = setTimeout(() => { if (section.isConnected && run?.running) load(); }, 5_000);
1027
1068
  };
1028
1069
  load();
1029
1070
  return section;
@@ -0,0 +1,17 @@
1
+ // The job fields the photo reader asks for (server/core/reader.js) and the
2
+ // capture screens label (server/ui/captures.js). No DOM, so both can use it.
3
+ // `needed` fields are read again by the larger model when they are not sure.
4
+ export const JOB_FIELDS = Object.freeze([
5
+ Object.freeze({ key: 'case_number', label: 'Case number', needed: true }),
6
+ Object.freeze({ key: 'practice', label: 'Practice', needed: true }),
7
+ Object.freeze({ key: 'dentist', label: 'Dentist', needed: false }),
8
+ Object.freeze({ key: 'work', label: 'Work', needed: true }),
9
+ Object.freeze({ key: 'teeth', label: 'Teeth', needed: false }),
10
+ Object.freeze({ key: 'shade', label: 'Shade', needed: false }),
11
+ Object.freeze({ key: 'due_date', label: 'Due date', needed: false }),
12
+ Object.freeze({ key: 'notes', label: 'Notes', needed: false }),
13
+ ]);
14
+
15
+ // Below this a needed field is read again (FLOW_READER_SURE_LINE can move
16
+ // that line), and the screens mark a value as not sure (amber).
17
+ export const SURE_LINE = 0.8;
@@ -0,0 +1,289 @@
1
+ // Capture (/captures): a photo of a handwritten lab form becomes a proposed
2
+ // job change (RFC #357, docs/DATA_MODEL.md "Storage"). One tap opens the
3
+ // phone's own camera. Reading runs on the server, so the person can shoot the
4
+ // next form at once. The tray lists the captures; a row opens the result
5
+ // sheet: the photo, the job, only the fields that change (old, then new;
6
+ // amber when the reader was not sure) and Apply at the foot. On a desk a
7
+ // photo can also be dropped or pasted on the page, and the sheet takes J, K
8
+ // and Enter.
9
+ import { lucide } from '/assets/ui/custom-fields.js';
10
+ import { JOB_FIELDS, SURE_LINE } from '/assets/ui/capture-fields.js';
11
+ import { isPhone } from '/assets/ui/mobile.js';
12
+ import { recordBar } from '/assets/ui/sidecar.js';
13
+
14
+ // The phone sends a smaller copy: about 1,600 px on the long side.
15
+ const LONG_SIDE = 1600;
16
+ const POLL_MS = 3000;
17
+ const POLL_MAX = 40;
18
+ const OFF = 'Capture needs a file store (FLOW_STORAGE).';
19
+ const STATE = { reading: 'Reading', ready: 'Ready', problem: 'Problem', done: 'Done' };
20
+ const TONE = { reading: 'info', ready: 'success', problem: 'warn', done: 'mute' };
21
+
22
+ const valueOf = (capture, key) => capture.read?.fields?.find((f) => f.key === key)?.value || '';
23
+
24
+ // One line for a capture: the practice and the work, else the case number.
25
+ function titleOf(capture) {
26
+ const practice = valueOf(capture, 'practice');
27
+ const work = valueOf(capture, 'work');
28
+ const text = [practice, work].filter(Boolean).join(' · ') || valueOf(capture, 'case_number');
29
+ if (text) return text;
30
+ if (capture.state === 'reading') return 'Reading…';
31
+ const problem = capture.problem ?? '';
32
+ return problem ? problem[0].toUpperCase() + problem.slice(1) : 'Photo';
33
+ }
34
+
35
+ async function shrink(file) {
36
+ try {
37
+ const bitmap = await createImageBitmap(file);
38
+ const scale = Math.min(1, LONG_SIDE / Math.max(bitmap.width, bitmap.height));
39
+ const canvas = document.createElement('canvas');
40
+ canvas.width = Math.round(bitmap.width * scale);
41
+ canvas.height = Math.round(bitmap.height * scale);
42
+ canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
43
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
44
+ return blob ?? file;
45
+ } catch {
46
+ // A type the browser cannot draw (HEIC on some browsers) goes as it is.
47
+ return file;
48
+ }
49
+ }
50
+
51
+ async function upload(file) {
52
+ const body = await shrink(file);
53
+ const token = await window.Clerk.session?.getToken();
54
+ const res = await fetch('/api/captures', {
55
+ method: 'POST',
56
+ headers: {
57
+ Authorization: `Bearer ${token}`,
58
+ 'Content-Type': body.type || 'image/jpeg',
59
+ 'Idempotency-Key': crypto.randomUUID(),
60
+ 'X-Flow-Capture-Source': isPhone() ? 'phone' : 'desk',
61
+ },
62
+ body,
63
+ });
64
+ const answer = await res.json().catch(() => ({}));
65
+ if (!res.ok) throw new Error(res.status === 404 ? OFF : answer.error || `HTTP ${res.status}`);
66
+ return answer.capture;
67
+ }
68
+
69
+ // The Capture button, on Capture and in the Jobs toolbar: one tap opens the
70
+ // phone's own camera; a desk picks files. `send(files)` uploads each image
71
+ // and calls `added(capture)`, then `done()`.
72
+ export function captureButton(ui, { icon = false, added = () => {}, done = () => {} } = {}) {
73
+ const { h, toast } = ui;
74
+ const picker = h('input', { type: 'file', accept: 'image/*', hidden: true });
75
+ if (isPhone()) picker.setAttribute('capture', 'environment');
76
+ else picker.multiple = true;
77
+ const button = icon
78
+ ? h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon fx-capture__shoot', type: 'button', 'aria-label': 'Capture', title: 'Capture', onclick: () => picker.click() }, lucide(h, 'camera'))
79
+ : h('button', { class: 'fx-btn fx-btn--primary fx-capture__shoot', type: 'button', onclick: () => picker.click() }, lucide(h, 'camera'), 'Capture');
80
+ const send = async (files) => {
81
+ for (const file of [...files].filter((f) => f.type.startsWith('image/'))) {
82
+ try {
83
+ added(await upload(file));
84
+ } catch (error) {
85
+ toast(error.message, 'danger', { stay: true });
86
+ }
87
+ }
88
+ done();
89
+ };
90
+ picker.addEventListener('change', () => {
91
+ const files = [...picker.files];
92
+ picker.value = '';
93
+ send(files);
94
+ });
95
+ return { nodes: [button, picker], send };
96
+ }
97
+
98
+ export async function renderCaptures(main, ui) {
99
+ const { h, flow, toast, notice, listHead, listFrame } = ui;
100
+ const shoot = captureButton(ui, {
101
+ added: (capture) => { rows = [capture, ...rows]; draw(); },
102
+ done: () => { polls = 0; load(); },
103
+ });
104
+ const head = listHead('Captures', { actions: shoot.nodes });
105
+ const list = h('ul', { class: 'fx-captures', 'aria-label': 'Captures' });
106
+ const frame = listFrame(main, list);
107
+ main.append(...head.nodes, frame.element);
108
+
109
+ let rows = [];
110
+ let labels = new Map(JOB_FIELDS.map((f) => [f.key, f.label]));
111
+ let polls = 0;
112
+ let timer = null;
113
+
114
+ const draw = () => {
115
+ const ready = rows.filter((c) => c.state === 'ready').length;
116
+ frame.count(ready ? `${ready} ready` : '');
117
+ list.replaceChildren(...(rows.length ? rows.map((capture) => h('li', {},
118
+ h('button', { class: 'fx-captures__row', type: 'button', disabled: capture.state === 'reading', onclick: () => openSheet(capture.id) },
119
+ h('span', { class: 'fx-captures__title', title: titleOf(capture) }, titleOf(capture)),
120
+ h('span', { class: 'fx-status', 'data-tone': TONE[capture.state] }, STATE[capture.state])))) : [h('li', { class: 'fx-empty' }, 'No captures.')]));
121
+ };
122
+
123
+ async function load() {
124
+ try {
125
+ ({ captures: rows } = await flow('/api/captures'));
126
+ draw();
127
+ } catch (error) {
128
+ const off = error.status === 404;
129
+ list.replaceChildren(h('li', {}, notice(off ? OFF : error.message, off ? 'warn' : 'danger', off ? 'Off.' : null)));
130
+ return;
131
+ }
132
+ clearTimeout(timer);
133
+ if (rows.some((c) => c.state === 'reading') && polls < POLL_MAX && frame.element.isConnected) {
134
+ polls += 1;
135
+ timer = setTimeout(load, POLL_MS);
136
+ }
137
+ }
138
+
139
+ // A desk: a photo dropped on the list or pasted on the page is a capture.
140
+ // `main` stays across pages, so the listeners hang on this page's frame.
141
+ frame.element.addEventListener('dragover', (event) => { if (event.dataTransfer?.types.includes('Files')) event.preventDefault(); });
142
+ frame.element.addEventListener('drop', (event) => {
143
+ if (!event.dataTransfer?.files.length) return;
144
+ event.preventDefault();
145
+ shoot.send(event.dataTransfer.files);
146
+ });
147
+ const paste = (event) => {
148
+ if (!frame.element.isConnected) { document.removeEventListener('paste', paste); return; }
149
+ const files = [...(event.clipboardData?.files ?? [])];
150
+ if (files.length && !document.querySelector('dialog[open]')) shoot.send(files);
151
+ };
152
+ document.addEventListener('paste', paste);
153
+
154
+ // The result sheet: a dialog on a desk, the whole screen on a phone (the
155
+ // job panel's layout: record bar, body, the action fixed at the foot).
156
+ async function openSheet(id) {
157
+ let capture;
158
+ try {
159
+ ({ capture } = await flow(`/api/captures/${id}`));
160
+ } catch (error) {
161
+ toast(error.message, 'danger');
162
+ return;
163
+ }
164
+ const element = h('dialog', { class: 'fx-dialog fx-task fx-capture', 'aria-label': 'Capture' });
165
+ const close = () => element.close();
166
+ const at = rows.findIndex((c) => c.id === id);
167
+ // The next capture that has a result; one still reading is skipped.
168
+ const step = (delta) => {
169
+ for (let i = at + delta; i >= 0 && i < rows.length; i += delta) {
170
+ if (rows[i].state === 'reading') continue;
171
+ close();
172
+ openSheet(rows[i].id);
173
+ return;
174
+ }
175
+ };
176
+ const proposal = capture.proposal ?? { changes: [] };
177
+ const match = capture.match ?? { outcome: 'none', jobs: [] };
178
+ const job = match.jobs.find((j) => j.jobId === proposal.jobId) ?? null;
179
+ const jobLine = job
180
+ ? `${job.caseRef ? `Job ${job.caseRef}` : job.jobId} · ${job.clientName ?? ''}`
181
+ : proposal.action === 'create' ? 'New job' : 'Pick a job';
182
+
183
+ // A field the work system owns: Apply leaves it; the person changes it
184
+ // there (until a two-way sync can send it, #357).
185
+ const system = proposal.action === 'update' && proposal.jobId ? proposal.jobId.split(':')[0] : null;
186
+ const systemName = system ? system[0].toUpperCase() + system.slice(1) : null;
187
+ const elsewhere = (change) => systemName && !change.field.startsWith('custom_');
188
+ const changeRow = (change) => h('div', { class: 'fx-capture__change', 'data-unsure': change.sure != null && change.sure < SURE_LINE ? 'true' : 'false' },
189
+ h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field,
190
+ elsewhere(change) ? h('span', { class: 'fx-capture__where', title: `Change this in ${systemName}` }, systemName) : null),
191
+ h('span', { class: 'fx-capture__from' }, change.from || '–'),
192
+ h('span', { class: 'fx-capture__arrow', 'aria-hidden': 'true' }, '→'),
193
+ h('span', { class: 'fx-capture__to' }, change.to));
194
+
195
+ const decide = async (decision) => {
196
+ try {
197
+ await flow(`/api/captures/${id}/decision`, { method: 'POST', body: JSON.stringify({ decision }) });
198
+ toast(decision === 'applied' ? 'Applied.' : 'Discarded.', 'success');
199
+ close();
200
+ load();
201
+ } catch (error) {
202
+ toast(error.message, 'danger', { stay: true });
203
+ }
204
+ };
205
+
206
+ const pickJob = async () => {
207
+ const chosen = await ui.pickOne({
208
+ title: 'Other job', label: 'Jobs', find: 'Find', empty: 'No jobs.', confirm: 'Use',
209
+ load: async () => {
210
+ const { jobs } = await flow('/api/invoicing/board');
211
+ const near = new Map(match.jobs.map((j, i) => [j.jobId, i]));
212
+ return jobs.map((j) => ({ ...j, jobId: `${j.sourceSystem}:${j.sourceJobRef}` }))
213
+ .sort((a, b) => (near.get(a.jobId) ?? 99) - (near.get(b.jobId) ?? 99));
214
+ },
215
+ item: (j) => ({ value: j.jobId, title: j.clientName ?? j.jobId, meta: j.caseRef ? `Job ${j.caseRef}` : null }),
216
+ });
217
+ const jobId = chosen?.jobId ?? null;
218
+ if (!jobId) return;
219
+ try {
220
+ await flow(`/api/captures/${id}/proposal`, { method: 'POST', body: JSON.stringify({ ...proposal, action: 'update', jobId }) });
221
+ close();
222
+ openSheet(id);
223
+ } catch (error) {
224
+ toast(error.message, 'danger');
225
+ }
226
+ };
227
+
228
+ // A capture the reader could not read: type the fields that find the job.
229
+ const typeIt = () => {
230
+ const inputs = ['case_number', 'practice', 'work'].map((key) => h('input', { class: 'fx-input', name: key, 'aria-label': labels.get(key), placeholder: labels.get(key) }));
231
+ const form = h('form', { class: 'fx-fields', onsubmit: async (event) => {
232
+ event.preventDefault();
233
+ const fields = Object.fromEntries(inputs.map((input) => [input.name, input.value.trim()]).filter(([, v]) => v));
234
+ try {
235
+ await flow(`/api/captures/${id}/match`, { method: 'POST', body: JSON.stringify({ fields }) });
236
+ close();
237
+ openSheet(id);
238
+ } catch (error) {
239
+ toast(error.message, 'danger');
240
+ }
241
+ } }, inputs, h('button', { class: 'fx-btn', type: 'submit' }, 'Find job'));
242
+ return form;
243
+ };
244
+
245
+ const ready = capture.state === 'ready';
246
+ const actions = ready ? [
247
+ h('button', { class: 'fx-btn', type: 'button', onclick: () => decide('discarded') }, 'Discard'),
248
+ match.jobs.length || proposal.action === 'update' ? h('button', { class: 'fx-btn', type: 'button', onclick: pickJob }, 'Other job') : null,
249
+ h('button', { class: 'fx-btn fx-btn--primary', type: 'button', autofocus: true, disabled: proposal.action === 'update' && !proposal.jobId, onclick: () => decide('applied') }, 'Apply'),
250
+ ] : capture.state === 'problem' ? [h('button', { class: 'fx-btn', type: 'button', onclick: () => decide('discarded') }, 'Discard')] : [];
251
+
252
+ element.append(
253
+ recordBar(h, {
254
+ title: jobLine, label: 'Capture', onBack: close,
255
+ prev: { label: 'Previous capture', disabled: at <= 0, onclick: () => step(-1) },
256
+ next: { label: 'Next capture', disabled: at < 0 || at >= rows.length - 1, onclick: () => step(1) },
257
+ }),
258
+ h('div', { class: 'fx-task__grid' }, h('div', { class: 'fx-task__main' },
259
+ h('header', { class: 'fx-task__head' }, h('span'), h('div', { class: 'fx-task__tools' },
260
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: close }, '✕'))),
261
+ capture.link ? h('img', { class: 'fx-capture__photo', src: capture.link.url, alt: 'Form photo' }) : null,
262
+ h('h2', { class: 'fx-task__title' }, jobLine),
263
+ capture.state === 'problem' ? notice(capture.problem, 'warn', null, { short: capture.problem }) : null,
264
+ capture.state === 'problem' ? typeIt() : null,
265
+ ready && proposal.changes.length ? h('div', { class: 'fx-capture__changes' }, proposal.changes.map(changeRow)) : null,
266
+ ready && !proposal.changes.length ? h('p', { class: 'fx-dim' }, 'No change.') : null,
267
+ h('div', { class: 'fx-task__actions' }, actions))));
268
+ element.addEventListener('click', (event) => { if (event.target === element) close(); });
269
+ // Keys: J next, K previous (not while typing). Apply has the focus, so
270
+ // Enter applies.
271
+ element.addEventListener('keydown', (event) => {
272
+ if (event.target.closest('input, textarea, select') || event.metaKey || event.ctrlKey || event.altKey) return;
273
+ if (event.key !== 'j' && event.key !== 'k') return;
274
+ event.preventDefault();
275
+ step(event.key === 'j' ? 1 : -1);
276
+ });
277
+ element.addEventListener('close', () => element.remove());
278
+ document.body.append(element);
279
+ element.showModal();
280
+ }
281
+
282
+ try {
283
+ const { fields } = await flow('/api/custom-fields');
284
+ labels = new Map([...labels, ...fields.map((f) => [`custom_${f.id}`, f.name])]);
285
+ } catch {
286
+ // The standard labels still show; a custom field shows its key.
287
+ }
288
+ await load();
289
+ }
@@ -17,15 +17,20 @@
17
17
  // widths() the saved widths
18
18
  // change(patch) saves { columns } or { widths }
19
19
  // hidden() [{ id, label }] the + menu lists (no hidden: no +)
20
- // extra(column, item) menu nodes before Move (Sort, Edit custom field)
20
+ // extra(column, item) menu nodes before Move (Edit custom field)
21
+ // sorting the heads sort the list (Jobs, Price list), as
22
+ // ClickUp's: { sortOf(columnId) (the sort key, or null),
23
+ // sorts(), change({ sorts }), dirWords(by) }
21
24
  // table({ columns, rows, lead, className }), per draw, answers the table:
22
25
  // columns the shown columns, in order: { id, label, kind, cell(row),
23
- // num?, className?, sort?(): node, icon?, fixed? }. An icon
26
+ // num?, className?, icon?, fixed? }. An icon
24
27
  // shows in place of the label (the label is its name). A fixed
25
28
  // column keeps its place: no drag, no Move, no Hide.
26
29
  // rows the <tbody> nodes (rowCells builds a row's cells)
27
30
  // lead <th> nodes before the columns (the tick column)
28
31
  //
32
+ // groupHead(h, config) is a grouped list's head row (below).
33
+ //
29
34
  // Cells every list shares: dateCell (a short date), personCell (a person's
30
35
  // initials) and fieldColumn (one of the lab's custom fields as a column, on
31
36
  // Jobs and on Invoices, where it is the invoice's job's value).
@@ -33,6 +38,7 @@ import { lucide } from '/assets/ui/custom-fields.js';
33
38
  import { endWidth, LEAD_WIDTH, MAX_WIDTH, minWidth, moveColumn, TAIL_WIDTH, tableWidth, widthOf } from '/assets/ui/job-view.js';
34
39
  import { applyPhoneRule, assignPhoneSlots, phoneSlot } from './mobile.js';
35
40
  import { initialsOf } from '/assets/ui/person.js';
41
+ import { ICONS, svg } from '/assets/ui/view-bar.js';
36
42
 
37
43
  const fullDate = (iso) => new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' });
38
44
  // A list date, short: Today, Tomorrow or Yesterday, else day/month (and
@@ -132,14 +138,73 @@ export function onRightButton(event, open) {
132
138
  else open();
133
139
  }
134
140
 
141
+ // A group's head row in a grouped list (Jobs, Price list): a fold button
142
+ // with the group's `badge` (its name and count), then `extra` (a sum). A
143
+ // click on the row folds the group or opens it. `lead` is the cells before
144
+ // the columns (a tick); `span` is how many cells the head covers after them.
145
+ const CHEVRON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3.5 4.5 4.5L6 12.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
146
+ export function groupHead(h, { label, badge, open, onFold, lead = [], span, extra = null }) {
147
+ const chevron = h('span', {});
148
+ chevron.innerHTML = CHEVRON;
149
+ const toggle = h('button', {
150
+ type: 'button', class: 'fx-table__fold', 'aria-expanded': String(open), 'aria-label': `${label}. ${open ? 'Fold' : 'Open'} the group`,
151
+ onclick: (event) => { event.stopPropagation(); onFold(); },
152
+ }, chevron.firstChild, badge);
153
+ return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: onFold },
154
+ lead, h('td', { colspan: String(span) }, h('span', { class: 'fx-table__group-row' }, toggle, extra)));
155
+ }
156
+
135
157
  export function columnTable(h, config) {
136
158
  const menu = columnPopover(h);
137
159
  const ids = () => config.order?.() ?? [];
138
160
  const widths = () => config.widths?.() ?? {};
139
161
 
162
+ // A column head's sort button: it shows on hover, or always while it
163
+ // sorts. A click sorts one way, again the other way, and a third time
164
+ // takes the sort off. A right-click on a sorting head opens a small menu:
165
+ // change the direction, or clear the sort.
166
+ const sorting = config.sorting ?? null;
167
+ function sortButton(column) {
168
+ const by = sorting?.sortOf(column.id);
169
+ if (!by) return null;
170
+ const sorts = sorting.sorts();
171
+ const change = (next) => sorting.change({ sorts: next });
172
+ const at = sorts.findIndex((s) => s.by === by);
173
+ const on = at >= 0 ? sorts[at] : null;
174
+ const [up, down] = sorting.dirWords(by);
175
+ const next = () => {
176
+ if (!on) change([{ by, dir: 'asc' }]);
177
+ else if (on.dir === 'asc') change(sorts.map((s, n) => (n === at ? { ...s, dir: 'desc' } : s)));
178
+ else change(sorts.filter((_, n) => n !== at));
179
+ };
180
+ const flip = () => change(sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)));
181
+ const words = on ? `Sorted ${on.dir === 'asc' ? up : down}. Click to ${on.dir === 'asc' ? `sort ${down}` : 'clear the sort'}` : `Sort by ${column.label}`;
182
+ return h('button', {
183
+ type: 'button', class: `fx-th-sort${on ? ' fx-th-sort--on' : ''}`, 'aria-label': words, title: words,
184
+ onclick: (event) => { event.stopPropagation(); next(); },
185
+ oncontextmenu: (event) => {
186
+ if (!on) return;
187
+ event.preventDefault();
188
+ event.stopPropagation();
189
+ onRightButton(event, () => menu.show([
190
+ h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { menu.close(); flip(); } },
191
+ h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${column.label}`), h('span', { class: 'fx-pop__end' }, svg(ICONS.tick))),
192
+ menu.item('ban', 'Clear sort', () => change(sorts.filter((_, n) => n !== at))),
193
+ ], event));
194
+ },
195
+ }, lucide(h, on ? (on.dir === 'asc' ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'),
196
+ on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
197
+ }
198
+
140
199
  function openMenu(column, event) {
141
200
  const parts = [];
142
201
  if (column) {
202
+ const by = sorting?.sortOf(column.id);
203
+ if (by) {
204
+ const [up, down] = sorting.dirWords(by);
205
+ parts.push(menu.item('arrow-up-narrow-wide', `Sort: ${up}`, () => sorting.change({ sorts: [{ by, dir: 'asc' }] })),
206
+ menu.item('arrow-down-wide-narrow', `Sort: ${down}`, () => sorting.change({ sorts: [{ by, dir: 'desc' }] })), menu.sep());
207
+ }
143
208
  parts.push(...(config.extra?.(column, menu.item) ?? []));
144
209
  if (!column.fixed) {
145
210
  const order = ids();
@@ -212,7 +277,7 @@ export function columnTable(h, config) {
212
277
  class: [c.num ? 'fx-num' : null, c.fixed ? null : 'fx-th--drag'].filter(Boolean).join(' ') || null,
213
278
  'data-col': c.id,
214
279
  oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => openMenu(c, event)); },
215
- }, h('span', { class: 'fx-th-cell' }, button, c.sort?.() ?? null), grip);
280
+ }, h('span', { class: 'fx-th-cell' }, button, sortButton(c)), grip);
216
281
  resizer(grip, c, n);
217
282
  if (!c.fixed) dragger(button, th, c);
218
283
  return th;
@@ -111,7 +111,7 @@ export async function renderCustomFields(main, ui) {
111
111
  const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
112
112
  const disabled = !['owner', 'admin'].includes(me?.person.role);
113
113
  const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
114
- const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled }, 'Create custom field');
114
+ const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled, title: 'New custom field' }, lucide(h, 'plus'), 'New');
115
115
  main.append(pageHeader('Custom fields', { help: { href: DOCS, label: 'How custom fields work' } }), host);
116
116
 
117
117
  let data = null;
@@ -309,7 +309,7 @@ export async function renderCustomFields(main, ui) {
309
309
  ]);
310
310
  const filterButton = h('button', { class: 'fx-chip fx-cfm__tool fx-cfm__tool--dashed', type: 'button' });
311
311
  const filterMenu = popMenu(filterButton, (close) => [
312
- item(view.archived ? 'check' : null, 'Show archived custom fields', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
312
+ item(view.archived ? 'check' : null, 'Archived', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
313
313
  ]);
314
314
  const toolbar = h('div', { class: 'fx-cfm__bar' }, search, typeButton, typeMenu, filterButton, filterMenu, h('span', { class: 'fx-cfm__gap' }), addButton);
315
315
  const table = h('div', { class: 'fx-cfm', role: 'table', 'aria-label': 'Custom fields' });
@@ -505,15 +505,9 @@ export async function renderCustomFields(main, ui) {
505
505
  parts.push(...group('archived', { text: 'Archived', icon: 'archive', color: 'gray' }, fields));
506
506
  }
507
507
  if (!count && (view.q || view.types.size)) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom field matches.'));
508
- if (!data.fields.length && !view.q && !view.types.size) {
509
- parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.'));
510
- }
508
+ else if (!count) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom fields'));
511
509
  table.replaceChildren(...parts);
512
- // The count is what the table shows, the work systems' fields too.
513
- foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`));
514
510
  }
515
- const foot = h('div', { class: 'fx-cfm__foot' });
516
- host.append(foot);
517
511
 
518
512
  async function load() {
519
513
  try {