@arcforgelabs/flow 2026.10.45 → 2026.10.47

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 (49) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +104 -2
  4. package/package.json +6 -2
  5. package/server/api/invoicing.js +56 -16
  6. package/server/app.js +4 -2
  7. package/server/auth/people.js +5 -3
  8. package/server/auth/routes.js +4 -1
  9. package/server/core/invoice-rules.js +34 -4
  10. package/server/core/invoices.js +40 -1
  11. package/server/core/invoicing-rules.js +20 -3
  12. package/server/core/job-board.js +1 -1
  13. package/server/core/sending.js +76 -22
  14. package/server/integrations/call-guard.js +21 -4
  15. package/server/integrations/sentry/index.js +14 -7
  16. package/server/integrations/xero/client.js +12 -2
  17. package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
  18. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  19. package/server/integrations/xero/routes.js +29 -5
  20. package/server/integrations/xero/runtime.js +5 -0
  21. package/server/integrations/xero/usage.js +74 -0
  22. package/server/ui/alerts.js +2 -1
  23. package/server/ui/app.js +110 -27
  24. package/server/ui/bulk-bar.js +18 -5
  25. package/server/ui/columns.js +54 -2
  26. package/server/ui/custom-fields.js +22 -7
  27. package/server/ui/flow.css +353 -45
  28. package/server/ui/invoicing.js +286 -64
  29. package/server/ui/job-board.js +10 -0
  30. package/server/ui/job-change.js +4 -3
  31. package/server/ui/job-view.js +14 -4
  32. package/server/ui/jobs.js +156 -56
  33. package/server/ui/kit.js +212 -51
  34. package/server/ui/list-views.js +50 -10
  35. package/server/ui/mobile.js +726 -0
  36. package/server/ui/monitoring.js +51 -0
  37. package/server/ui/nav-pins.js +41 -0
  38. package/server/ui/page.js +110 -5
  39. package/server/ui/pager.js +57 -10
  40. package/server/ui/price-list.js +1 -1
  41. package/server/ui/price-reconcile.js +12 -12
  42. package/server/ui/reconcile.js +19 -18
  43. package/server/ui/routes.js +1 -1
  44. package/server/ui/saved-views.js +6 -5
  45. package/server/ui/sidecar.js +106 -6
  46. package/server/ui/view-bar.js +22 -11
  47. package/server/ui/xero-import.js +48 -19
  48. package/server/ui/xero-usage-model.js +106 -0
  49. package/server/ui/xero-usage.js +133 -0
package/server/ui/kit.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // functions, so a card is the real thing. Reuse one or give it an option;
6
6
  // never copy it into a page (AGENTS.md, "UI: reuse first").
7
7
  import { lucide } from '/assets/ui/custom-fields.js';
8
+ import { applyPhoneRule } from './mobile.js';
8
9
  import { named } from '/assets/ui/tip.js';
9
10
 
10
11
  // h('p', { class: 'x', onclick }, 'text', child) — children are text or nodes.
@@ -53,32 +54,38 @@ export function holdButton(button, ms) {
53
54
  // `count` is a live number beside the name (2 to check); `help` is a docs
54
55
  // link, an icon with its name as a tip.
55
56
  export function pageHeader(title, { count = null, help = null, actions = [] } = {}) {
56
- return h('header', { class: 'fx-ph' },
57
+ return applyPhoneRule(h('header', { class: 'fx-ph' },
57
58
  h('div', { class: 'fx-ph__text' },
58
59
  h('h1', { class: 'fx-title' }, title),
59
60
  count != null ? h('span', { class: 'fx-ph__count' }, count) : null,
60
61
  help ? helpLink(help) : null),
61
- actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
62
+ actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null), 'pageHeader');
62
63
  }
63
64
 
64
65
  // The top of a list page (Jobs, Invoices, Reconcile), laid out the same on
65
66
  // every list and in Board and List alike, so nothing moves when a person
66
- // switches: the title row, a row for saved view tabs (`views`: the page's
67
- // savedViews element, else an empty slot that stays hidden), then one toolbar with `start` at the left (tabs, Group,
68
- // Sort, Filter) and `end` at the right (search, Board/List), then the page's
69
- // `actions` (New invoice) last, as Claude's own lists do. `line` draws the
70
- // rule that tabs sit on; `sticky` keeps the toolbar at the top while the rows
71
- // scroll up under it, with a shadow once they do. Append `nodes` to the page.
72
- export function listHead(title, { count = null, help = null, actions = [], start = [], end = [], line = false, sticky = false, views: tabs = null } = {}) {
67
+ // switches: the title row, then one toolbar with `start` at the left (tabs,
68
+ // Group, Sort, Filter) and `end` at the right (search, Board/List), then the
69
+ // page's `actions` (New invoice) last, as Claude's own lists do. `controls`
70
+ // (the page's Group, Sort and Filter) sit with the saved view tabs at their
71
+ // right (`views`: the page's savedViews element, else an empty slot that
72
+ // stays hidden), at the end of `start`, or of `end` when `controlsAt` is
73
+ // 'end'. `line` draws the rule that tabs sit on; `sticky` keeps the toolbar
74
+ // at the top while the rows scroll up under it, with a shadow once they do.
75
+ // Append `nodes` to the page.
76
+ export function listHead(title, { count = null, help = null, actions = [], start = [], end = [], controls = null, controlsAt = 'start', line = false, sticky = false, views: tabs = null } = {}) {
73
77
  const views = tabs ?? h('div', { class: 'fx-views', role: 'group', 'aria-label': 'Views', hidden: true });
74
- const bar = h('div', { class: 'fx-listhead', 'data-sticky': sticky },
75
- h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, start, h('div', { class: 'fx-toolbar__end' }, end, actions)));
78
+ const tools = h('div', { class: 'fx-toolbar__tools' }, controls, views);
79
+ const left = controlsAt === 'end' ? [start].flat() : [start, tools].flat();
80
+ const right = controlsAt === 'end' ? [tools, end].flat() : [end].flat();
81
+ const bar = applyPhoneRule(h('div', { class: 'fx-listhead', 'data-sticky': sticky },
82
+ h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, left, h('div', { class: 'fx-toolbar__end' }, right, actions))), 'listHead');
76
83
  const edge = sticky ? h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' }) : null;
77
84
  if (edge && typeof IntersectionObserver === 'function') {
78
85
  const top = window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1;
79
86
  new IntersectionObserver(([entry]) => bar.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${top}px 0px 0px 0px` }).observe(edge);
80
87
  }
81
- return { nodes: [pageHeader(title, { count, help }), views, edge, bar].filter(Boolean), views, bar };
88
+ return { nodes: [pageHeader(title, { count, help }), edge, bar].filter(Boolean), views, bar };
82
89
  }
83
90
 
84
91
  // The window a list or board sits in under listHead, as HubSpot's: on a
@@ -93,7 +100,7 @@ export function listFrame(main, content) {
93
100
  const tools = h('div', { class: 'fx-frame__tools' });
94
101
  const body = h('div', { class: 'fx-frame__body' }, content);
95
102
  const view = h('div', { class: 'fx-frame__view' }, body);
96
- const element = h('section', { class: 'fx-frame' }, view, h('footer', { class: 'fx-frame__foot' }, count, tools));
103
+ const element = applyPhoneRule(h('section', { class: 'fx-frame' }, view, h('footer', { class: 'fx-frame__foot' }, count, tools)), 'listFrame');
97
104
  edgeFade(body, { target: view, edges: ['bottom', 'left', 'right'] });
98
105
  return { element, body, view, tools, count: (text) => { count.textContent = text ?? ''; } };
99
106
  }
@@ -143,9 +150,9 @@ export function sectionTitle(text, help = null) {
143
150
  }
144
151
 
145
152
  export function notice(text, tone = 'info', title = null) {
146
- return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
153
+ return applyPhoneRule(h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
147
154
  title ? h('strong', {}, title) : null,
148
- h('span', {}, text));
155
+ h('span', {}, text)), 'notice');
149
156
  }
150
157
 
151
158
  // A field that fails a check glows red, like a required field, until the
@@ -153,37 +160,63 @@ export function notice(text, tone = 'info', title = null) {
153
160
  // `on` false clears it. Answers the field.
154
161
  export function fieldError(field, on = true) {
155
162
  if (!on) { field.removeAttribute('aria-invalid'); return field; }
163
+ applyPhoneRule(field, 'fieldError');
156
164
  field.setAttribute('aria-invalid', 'true');
157
165
  field.addEventListener('input', () => field.removeAttribute('aria-invalid'), { once: true });
158
166
  return field;
159
167
  }
160
168
 
161
- export function toast(text, tone = 'info') {
169
+ // A short result after an action. With `stay: true` it stays until the person
170
+ // closes it (for a danger a failed press must not pass unseen).
171
+ export function toast(text, tone = 'info', { stay = false } = {}) {
162
172
  const region = document.getElementById('toasts');
163
- const item = h('div', { class: 'fx-toast', 'data-tone': tone, role: 'status' }, text);
173
+ const item = applyPhoneRule(h('div', {
174
+ class: stay ? 'fx-toast fx-toast--stay' : 'fx-toast',
175
+ 'data-tone': tone,
176
+ role: tone === 'danger' ? 'alert' : 'status',
177
+ }, h('span', { class: 'fx-toast__text' }, text)), 'toast');
178
+ if (stay) {
179
+ item.append(h('button', {
180
+ type: 'button', class: 'fx-toast__close', 'aria-label': 'Close', title: 'Close',
181
+ onclick: () => item.remove(),
182
+ }, lucide(h, 'x')));
183
+ } else {
184
+ setTimeout(() => item.remove(), 4200);
185
+ }
164
186
  region.append(item);
165
- setTimeout(() => item.remove(), 4200);
166
187
  }
167
188
 
168
189
  // A modal built on <dialog>: resolves with the button's value, or null.
169
- export function dialog({ title, body, confirm, tone = 'primary' }) {
190
+ export function dialog({ title, body, confirm, tone = 'primary', choices = null }) {
170
191
  return new Promise((resolve) => {
192
+ const choiceList = choices
193
+ ? h('div', { class: 'fx-sheet__choices', role: 'group', 'aria-label': title },
194
+ choices.map((choice) => h('button', { class: 'fx-sheet__choice', type: 'submit', value: choice.value },
195
+ h('span', {}, choice.label),
196
+ choice.detail == null ? null : h('span', { class: 'fx-tabs__count' }, String(choice.detail)))))
197
+ : null;
198
+ const actions = choices ? null : h('div', { class: 'fx-dialog__actions' },
199
+ h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
200
+ h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm));
171
201
  const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
172
202
  h('h2', { class: 'fx-dialog__title' }, title),
173
- body,
174
- h('div', { class: 'fx-dialog__actions' },
175
- h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
176
- h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
177
- const element = h('dialog', { class: 'fx-dialog' }, form);
203
+ choiceList ?? body,
204
+ actions);
205
+ const element = applyPhoneRule(h('dialog', { class: 'fx-dialog' }, form), 'dialog');
178
206
  element.addEventListener('close', () => {
179
- const ok = element.returnValue === 'ok';
180
- const data = ok ? new FormData(form) : null;
207
+ const value = element.returnValue;
181
208
  element.remove();
182
- resolve(ok ? data : null);
209
+ if (choices) {
210
+ resolve(value && value !== 'cancel' ? value : null);
211
+ return;
212
+ }
213
+ const ok = value === 'ok';
214
+ resolve(ok ? new FormData(form) : null);
183
215
  });
216
+ if (choices) element.addEventListener('click', (event) => { if (event.target === element) element.close(); });
184
217
  document.body.append(element);
185
218
  element.showModal();
186
- element.querySelector('input, select, button[value="ok"]')?.focus();
219
+ element.querySelector(choices ? '.fx-sheet__choice' : 'input, select, button[value="ok"]')?.focus();
187
220
  });
188
221
  }
189
222
 
@@ -194,11 +227,11 @@ export function dialog({ title, body, confirm, tone = 'primary' }) {
194
227
  export function settingRow({ label, hint, control, wide = false, tone = null }) {
195
228
  const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
196
229
  const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
197
- return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
230
+ return applyPhoneRule(h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
198
231
  h('div', { class: 'fx-setting__text' },
199
232
  id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
200
233
  hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
201
- h('div', { class: 'fx-setting__control' }, control));
234
+ h('div', { class: 'fx-setting__control' }, control)), 'settingRow');
202
235
  }
203
236
  settingRow.next = 0;
204
237
 
@@ -206,26 +239,126 @@ settingRow.next = 0;
206
239
 
207
240
  let rowMenus = 0;
208
241
 
242
+ function menuGlyph(icon) {
243
+ if (icon instanceof Node) return icon.isConnected ? icon.cloneNode(true) : icon;
244
+ return icon ? lucide(h, icon) : null;
245
+ }
246
+
209
247
  // A row's ⋯ menu, as on the Custom fields page. The menu lines up with the
210
248
  // button's right edge; `align: 'start'` lines it up with its left edge.
211
- // `trigger` swaps the ⋯ icon for the given contents (the sidebar's More).
212
- export function rowMenu(label, items, { trigger = null, className = 'fx-cfm__more', align = 'end' } = {}) {
249
+ // `place: 'side'` floats it to the right of the button (the sidebar's More),
250
+ // and moves focus into the menu and back to the button on close.
251
+ // `trigger` swaps the ⋯ icon for the given contents.
252
+ // An item is `{ icon, text, run, tone }` or `'-'` (a line). `icon` is a Lucide
253
+ // name or a node. `trailing` is a control at the row's end (the pin) that
254
+ // does not close the menu: `{ icon, label, pressed, run, locked }`. `locked`
255
+ // draws the icon and does not take a click. `items` may be a function, read
256
+ // again each time the menu opens. `panel.refresh()` redraws it while open.
257
+ export function rowMenu(label, items, { trigger = null, className = 'fx-cfm__more', align = 'end', place = 'below' } = {}) {
213
258
  const id = `fx-row-menu-${++rowMenus}`;
214
- const button = h('button', { class: className, type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, trigger ?? lucide(h, 'ellipsis'));
215
- const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
216
- panel.addEventListener('toggle', (event) => {
217
- button.setAttribute('aria-expanded', String(event.newState === 'open'));
218
- if (event.newState !== 'open') return;
219
- const close = () => panel.hidePopover();
220
- panel.replaceChildren(...items.map((entry) => (entry === '-'
221
- ? h('div', { class: 'fx-pop__sep' })
222
- : h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
223
- h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
259
+ const button = applyPhoneRule(h('button', { class: className, type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, trigger ?? lucide(h, 'ellipsis')), 'rowMenu');
260
+ const panel = h('div', { class: place === 'side' ? 'fx-pop fx-pop--side' : 'fx-pop', id, popover: 'auto', role: 'menu' });
261
+ const entries = () => (typeof items === 'function' ? items() : items);
262
+ let picking = false;
263
+
264
+ function renderEntry(entry) {
265
+ if (entry === '-') return h('div', { class: 'fx-pop__sep' });
266
+ const item = h('button', {
267
+ type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone,
268
+ onclick: () => { picking = true; panel.hidePopover(); entry.run(); },
269
+ }, h('span', { class: 'fx-pop__tick' }, menuGlyph(entry.icon)), h('span', {}, entry.text));
270
+ if (!entry.trailing) return item;
271
+ const trail = entry.trailing;
272
+ const mark = menuGlyph(trail.icon);
273
+ const control = trail.locked
274
+ ? h('span', { class: 'fx-pop__pin fx-pop__pin--fixed', role: 'img', 'aria-label': trail.label }, mark)
275
+ : h('button', {
276
+ type: 'button', class: 'fx-pop__pin', 'aria-label': trail.label, 'aria-pressed': trail.pressed ? 'true' : 'false',
277
+ onclick: (event) => { event.stopPropagation(); trail.run(); },
278
+ }, mark);
279
+ return h('div', { class: 'fx-pop__row', role: 'none' }, item, control);
280
+ }
281
+
282
+ function placePanel() {
224
283
  const at = button.getBoundingClientRect();
284
+ const margin = 8;
285
+ const narrow = place === 'side' && window.matchMedia('(max-width: 820px)').matches;
286
+ if (narrow) {
287
+ // On a phone the sidebar is the slide-out. The menu stays inside it.
288
+ const drawer = button.closest('.fx-nav')?.getBoundingClientRect();
289
+ const left = Math.round((drawer?.left ?? at.left) + margin);
290
+ const width = Math.max(160, Math.round((drawer?.width ?? at.width) - margin * 2));
291
+ panel.style.width = `${width}px`;
292
+ panel.style.minWidth = '0';
293
+ let top = Math.round(at.bottom + 6);
294
+ if (top + panel.offsetHeight > innerHeight - margin) top = Math.max(margin, Math.round(innerHeight - margin - panel.offsetHeight));
295
+ panel.style.top = `${top}px`;
296
+ panel.style.left = `${left}px`;
297
+ return;
298
+ }
299
+ panel.style.width = '';
300
+ panel.style.minWidth = '';
301
+ if (place === 'side') {
302
+ const gap = 8;
303
+ let left = Math.round(at.right + gap);
304
+ if (left + panel.offsetWidth > innerWidth - margin) left = Math.round(at.left - gap - panel.offsetWidth);
305
+ panel.style.left = `${Math.max(margin, Math.min(left, innerWidth - panel.offsetWidth - margin))}px`;
306
+ let top = Math.round(at.top);
307
+ panel.style.top = `${Math.max(margin, Math.min(top, innerHeight - panel.offsetHeight - margin))}px`;
308
+ return;
309
+ }
225
310
  panel.style.top = `${Math.round(at.bottom + 6)}px`;
226
311
  const left = align === 'start' ? at.left : at.right - panel.offsetWidth;
227
- panel.style.left = `${Math.round(Math.max(8, Math.min(left, innerWidth - panel.offsetWidth - 8)))}px`;
312
+ panel.style.left = `${Math.round(Math.max(margin, Math.min(left, innerWidth - panel.offsetWidth - margin)))}px`;
313
+ }
314
+
315
+ function draw(keepFocus) {
316
+ const active = document.activeElement;
317
+ const row = active?.closest?.('.fx-pop__row');
318
+ const text = row?.querySelector('[role="menuitem"]')?.textContent?.trim() ?? '';
319
+ const onPin = Boolean(active?.classList?.contains('fx-pop__pin'));
320
+ panel.replaceChildren(...entries().map(renderEntry));
321
+ if (!keepFocus || !text) return;
322
+ const next = [...panel.querySelectorAll('.fx-pop__row')].find((candidate) => candidate.querySelector('[role="menuitem"]')?.textContent?.trim() === text);
323
+ (onPin ? next?.querySelector('.fx-pop__pin') : next?.querySelector('[role="menuitem"]'))?.focus();
324
+ }
325
+
326
+ panel.refresh = () => {
327
+ if (!panel.matches(':popover-open')) return;
328
+ draw(true);
329
+ placePanel();
330
+ };
331
+
332
+ panel.addEventListener('toggle', (event) => {
333
+ const open = event.newState === 'open';
334
+ button.setAttribute('aria-expanded', String(open));
335
+ if (!open) {
336
+ // A chosen row moves focus itself. Anything else (Escape, a click
337
+ // outside) puts focus back on the button that opened the menu.
338
+ if (place === 'side' && !picking) button.focus();
339
+ picking = false;
340
+ return;
341
+ }
342
+ draw(false);
343
+ placePanel();
344
+ if (place === 'side') panel.querySelector('[role="menuitem"]')?.focus();
228
345
  });
346
+
347
+ if (place === 'side') {
348
+ panel.addEventListener('keydown', (event) => {
349
+ if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
350
+ const stops = [...panel.querySelectorAll('button')];
351
+ if (!stops.length) return;
352
+ event.preventDefault();
353
+ const index = Math.max(0, stops.indexOf(document.activeElement));
354
+ const next = event.key === 'Home' ? 0
355
+ : event.key === 'End' ? stops.length - 1
356
+ : event.key === 'ArrowDown' ? (index + 1) % stops.length
357
+ : (index - 1 + stops.length) % stops.length;
358
+ stops[next].focus();
359
+ });
360
+ }
361
+
229
362
  return [button, panel];
230
363
  }
231
364
 
@@ -249,31 +382,59 @@ export function tileCard({ href, mark, title, status, blurb, meta, action = 'Man
249
382
  const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, lucide(h, mark)) : mark;
250
383
  const pill = status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null;
251
384
  if (row) {
252
- return h('a', { class: 'fx-tile', href, onclick: inPlace },
385
+ return applyPhoneRule(h('a', { class: 'fx-tile', href, onclick: inPlace },
253
386
  logo,
254
387
  h('h3', {}, title),
255
- pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null));
388
+ pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null)), 'tileCard');
256
389
  }
257
- return h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
390
+ return applyPhoneRule(h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
258
391
  h('header', { class: 'fx-int-card__head' }, logo, h('h3', {}, title), pill),
259
392
  blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
260
- h('span', { class: 'fx-int-card__btn' }, action));
393
+ h('span', { class: 'fx-int-card__btn' }, action)), 'tileCard');
261
394
  }
262
395
 
263
396
  // A titled group of one-line cards, three columns where they fit, with "Show all" at the right
264
397
  // when `href` is given (Claude's Customize page).
265
398
  export function tileSection(title, cards, { href, more = 'Show all' } = {}) {
266
- return h('section', { class: 'fx-tiles' },
399
+ return applyPhoneRule(h('section', { class: 'fx-tiles' },
267
400
  h('header', { class: 'fx-tiles__head' },
268
401
  h('h2', {}, title),
269
402
  href ? h('a', { class: 'fx-tiles__more', href, onclick: inPlace }, more, lucide(h, 'chevron-right')) : null),
270
- h('div', { class: 'fx-tiles__grid' }, cards));
403
+ h('div', { class: 'fx-tiles__grid' }, cards)), 'tileSection');
404
+ }
405
+
406
+ // A dialog that fills the screen has no pixel outside it, so an accessibility
407
+ // check cannot tell it is modal and reads the page underneath. While one is
408
+ // open, every other top-level node is inert. The attribute is what the check
409
+ // reads; the browser's own modal behaviour is unchanged.
410
+ function shieldBehindDialogs() {
411
+ const top = [...document.querySelectorAll('dialog[open]')].at(-1) ?? null;
412
+ for (const child of document.body.children) {
413
+ if (top && child !== top) child.setAttribute('inert', '');
414
+ else child.removeAttribute('inert');
415
+ }
416
+ }
417
+ // Node tests import this module and have no HTMLDialogElement. The wrap only
418
+ // matters in the browser, where showModal and close are the three call sites.
419
+ if (typeof HTMLDialogElement !== 'undefined') {
420
+ const openDialog = HTMLDialogElement.prototype.showModal;
421
+ const closeDialog = HTMLDialogElement.prototype.close;
422
+ HTMLDialogElement.prototype.showModal = function showModal() {
423
+ const result = openDialog.call(this);
424
+ shieldBehindDialogs();
425
+ return result;
426
+ };
427
+ HTMLDialogElement.prototype.close = function close(...args) {
428
+ const result = closeDialog.apply(this, args);
429
+ shieldBehindDialogs();
430
+ return result;
431
+ };
271
432
  }
272
433
 
273
434
  // The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
274
435
  export function crumbs(...trail) {
275
436
  const here = trail.pop();
276
- return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
437
+ return applyPhoneRule(h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
277
438
  ...trail.flatMap(([href, label]) => [h('a', { href, onclick: inPlace }, label), h('span', { 'aria-hidden': 'true' }, '›')]),
278
- h('span', {}, here));
439
+ h('span', {}, here)), 'crumbs');
279
440
  }
@@ -3,34 +3,74 @@
3
3
  // Filter, Columns), and one can be the person's default, which opens when
4
4
  // they go to the page. No DOM: the server checks a saved list with the same
5
5
  // rules the page uses (server/auth/people.js).
6
- import { cleanFilterView, filterJobs, normaliseJobsView } from './job-view.js';
6
+ import { caseNumber } from './job-board.js';
7
+ import { cleanFilterView, fieldIdOf, filterJobs, KIND_OF_TYPE, normaliseJobsView } from './job-view.js';
7
8
 
8
- // The Invoices filter's fields (invoicing.js). The tabs are the status, so
9
- // Status is not here. The page shows the chip (view-bar.js); the server keeps
10
- // the rows (GET /api/invoicing/invoices), so a filter reaches every invoice
11
- // of a tab.
9
+ // The Invoices filter's fields (invoicing.js): the list's own columns, then
10
+ // the job columns the Columns menu offers (Case, Practitioner, Shipped).
11
+ // Custom fields ('field:<id>') are fields too; their kind comes from their
12
+ // type. The page shows the chip (view-bar.js); the server keeps the rows
13
+ // (GET /api/invoicing/invoices), so a filter reaches every invoice of a tab.
12
14
  export const INVOICE_FILTER_FIELDS = {
13
15
  client: { label: 'Client', kind: 'choice', icon: 'building-2' },
14
16
  number: { label: 'Number', kind: 'text', icon: 'hash' },
15
17
  reference: { label: 'Reference', kind: 'text', icon: 'text' },
16
18
  date: { label: 'Date', kind: 'date', icon: 'calendar' },
19
+ due: { label: 'Due', kind: 'date', icon: 'calendar' },
17
20
  total: { label: 'Total', kind: 'number', icon: 'dollar-sign' },
21
+ status: { label: 'Status', kind: 'choice', icon: 'circle-dot' },
22
+ linked: { label: 'Linked', kind: 'choice', icon: 'link' },
18
23
  problem: { label: 'Problem', kind: 'check', icon: 'triangle-alert' },
24
+ case: { label: 'Case', kind: 'text', icon: 'briefcase' },
25
+ practitioner: { label: 'Practitioner', kind: 'choice', icon: 'stethoscope' },
26
+ shipped: { label: 'Shipped', kind: 'date', icon: 'calendar' },
19
27
  };
20
28
 
29
+ // What the Status column shows. `sending` replaces the tab while an invoice
30
+ // is on its way to the accounting system.
31
+ export const INVOICE_FILTER_STATUSES = Object.freeze([
32
+ { value: 'pending', label: 'Pending' },
33
+ { value: 'ready', label: 'Ready' },
34
+ { value: 'draft', label: 'Draft' },
35
+ { value: 'approved', label: 'Approved' },
36
+ { value: 'sent', label: 'Sent' },
37
+ { value: 'void', label: 'Void' },
38
+ { value: 'sending', label: 'Sending' },
39
+ ]);
40
+
21
41
  export const invoiceFilterKind = (field) => INVOICE_FILTER_FIELDS[field]?.kind ?? null;
22
42
 
43
+ // A field's kind for this lab: a built-in field, or a custom field's type.
44
+ // `fields` is the lab's custom fields ({ id, type }).
45
+ export function invoiceKindOf(fields) {
46
+ const type = new Map((fields ?? []).map((f) => [f.id, f.type]));
47
+ return (field) => INVOICE_FILTER_FIELDS[field]?.kind ?? KIND_OF_TYPE[type.get(fieldIdOf(field) ?? '')] ?? null;
48
+ }
49
+
23
50
  // An invoice's value for a filter field or a sort, as they compare it. A
24
- // draft has no issue date: it uses the day it was made.
51
+ // draft has no issue date: it uses the day it was made. A job column reads
52
+ // the invoice's job; a custom field reads that job's value (`fields`).
25
53
  export function invoiceValue(invoice, field) {
54
+ const job = invoice.job ?? null;
26
55
  switch (field) {
27
56
  case 'client': return invoice.contactName ?? null;
28
57
  case 'number': return invoice.number ?? null;
29
58
  case 'reference': return invoice.reference ?? null;
30
59
  case 'date': return invoice.issueDate ?? (invoice.createdAt ? new Date(invoice.createdAt).toISOString().slice(0, 10) : null);
60
+ case 'due': return invoice.dueDate ?? null;
31
61
  case 'total': return invoice.totalCents == null ? null : invoice.totalCents / 100;
62
+ case 'status': return invoice.sending ? 'sending' : invoice.tab ?? null;
63
+ case 'linked': return job?.sourceSystem ?? null;
32
64
  case 'problem': return (invoice.problems?.length ?? 0) > 0;
33
- default: return null;
65
+ case 'case': return job ? caseNumber(job) : null;
66
+ case 'practitioner': return job?.practitioner ?? null;
67
+ case 'shipped': return job?.shippedOn ?? null;
68
+ default: {
69
+ const id = fieldIdOf(field);
70
+ if (!id) return null;
71
+ const value = invoice.fields?.[id];
72
+ return value === undefined ? null : value;
73
+ }
34
74
  }
35
75
  }
36
76
 
@@ -41,8 +81,8 @@ export const INVOICE_SORTS = Object.freeze(['number', 'client', 'reference', 'da
41
81
  // them (an empty value last, either way), then `offset` and `limit` cut the
42
82
  // page. `total` and `totalCents` are of every kept row. The rows come in
43
83
  // newest first; that order breaks ties.
44
- export function invoicePage(invoices, { view, sort = null, dir = 'asc', offset = 0, limit = 100, today }) {
45
- const kept = filterJobs(invoices, view, { valueOf: invoiceValue, kindOf: invoiceFilterKind, today });
84
+ export function invoicePage(invoices, { view, sort = null, dir = 'asc', offset = 0, limit = 100, today, kindOf = invoiceFilterKind, me = null }) {
85
+ const kept = filterJobs(invoices, view, { valueOf: invoiceValue, kindOf, late: null, me, today });
46
86
  const sign = dir === 'desc' ? -1 : 1;
47
87
  const ordered = INVOICE_SORTS.includes(sort)
48
88
  ? kept.map((invoice, i) => [invoice, invoiceValue(invoice, sort), i]).sort(([, x, i], [, y, j]) => {
@@ -75,7 +115,7 @@ const CUSTOM_FIELD = /^field:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-
75
115
  const kindIn = (fields, custom) => (field) => (Object.hasOwn(fields, field) ? fields[field].kind : custom && CUSTOM_FIELD.test(field) ? '*' : null);
76
116
 
77
117
  // The Invoices and Reconcile views: their filter.
78
- export const normaliseInvoicesView = (value) => cleanFilterView(value, kindIn(INVOICE_FILTER_FIELDS, false));
118
+ export const normaliseInvoicesView = (value) => cleanFilterView(value, kindIn(INVOICE_FILTER_FIELDS, true));
79
119
  export const normaliseReconcileView = (value) => cleanFilterView(value, kindIn(RECONCILE_FILTER_FIELDS, true));
80
120
 
81
121
  // The pages with saved views. Each is named in a switch, never looked up