@arcforgelabs/flow 2026.10.46 → 2026.10.48

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 (63) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +2 -2
  4. package/package.json +1 -1
  5. package/server/api/invoicing.js +89 -22
  6. package/server/auth/people.js +3 -1
  7. package/server/auth/routes.js +4 -1
  8. package/server/core/contacts.js +160 -0
  9. package/server/core/directory.js +4 -9
  10. package/server/core/invoice-rules.js +34 -4
  11. package/server/core/invoices.js +93 -1
  12. package/server/core/invoicing-rules.js +20 -3
  13. package/server/core/job-board.js +1 -1
  14. package/server/core/sending.js +141 -25
  15. package/server/db/doctor.js +1 -1
  16. package/server/db/index.js +19 -0
  17. package/server/db/migrations/0021_flow_schema.sql +68 -0
  18. package/server/db/migrations/0022_contacts.sql +11 -0
  19. package/server/db/migrations/meta/0021_snapshot.json +4377 -0
  20. package/server/db/migrations/meta/0022_snapshot.json +4446 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +15 -0
  23. package/server/integrations/port.js +2 -0
  24. package/server/integrations/schema.js +3 -2
  25. package/server/integrations/sentry/index.js +14 -7
  26. package/server/integrations/setup.js +1 -0
  27. package/server/integrations/xero/catalogue.js +82 -14
  28. package/server/integrations/xero/client.js +37 -0
  29. package/server/integrations/xero/create-contact.js +126 -0
  30. package/server/integrations/xero/delete-invoice.js +104 -0
  31. package/server/integrations/xero/drafts.js +19 -8
  32. package/server/integrations/xero/index.js +9 -0
  33. package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
  34. package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
  35. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  36. package/server/integrations/xero/routes.js +21 -0
  37. package/server/integrations/xero/settings.js +3 -0
  38. package/server/ui/alerts.js +2 -1
  39. package/server/ui/app.js +113 -28
  40. package/server/ui/bulk-bar.js +18 -5
  41. package/server/ui/columns.js +5 -2
  42. package/server/ui/contacts.js +260 -0
  43. package/server/ui/custom-fields.js +22 -7
  44. package/server/ui/flow.css +275 -24
  45. package/server/ui/invoicing.js +374 -70
  46. package/server/ui/job-board.js +10 -0
  47. package/server/ui/job-change.js +4 -3
  48. package/server/ui/job-view.js +3 -1
  49. package/server/ui/jobs.js +150 -19
  50. package/server/ui/kit.js +220 -45
  51. package/server/ui/list-views.js +50 -10
  52. package/server/ui/mobile.js +729 -0
  53. package/server/ui/monitoring.js +51 -0
  54. package/server/ui/nav-pins.js +44 -0
  55. package/server/ui/pager.js +57 -10
  56. package/server/ui/price-list.js +1 -1
  57. package/server/ui/price-reconcile.js +12 -12
  58. package/server/ui/reconcile.js +18 -17
  59. package/server/ui/routes.js +1 -1
  60. package/server/ui/saved-views.js +2 -1
  61. package/server/ui/sidecar.js +106 -6
  62. package/server/ui/view-bar.js +22 -11
  63. package/server/ui/xero-import.js +48 -19
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,12 +54,12 @@ 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
@@ -77,8 +78,8 @@ export function listHead(title, { count = null, help = null, actions = [], start
77
78
  const tools = h('div', { class: 'fx-toolbar__tools' }, controls, views);
78
79
  const left = controlsAt === 'end' ? [start].flat() : [start, tools].flat();
79
80
  const right = controlsAt === 'end' ? [tools, end].flat() : [end].flat();
80
- const bar = h('div', { class: 'fx-listhead', 'data-sticky': sticky },
81
- h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, left, h('div', { class: 'fx-toolbar__end' }, right, actions)));
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');
82
83
  const edge = sticky ? h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' }) : null;
83
84
  if (edge && typeof IntersectionObserver === 'function') {
84
85
  const top = window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1;
@@ -99,7 +100,7 @@ export function listFrame(main, content) {
99
100
  const tools = h('div', { class: 'fx-frame__tools' });
100
101
  const body = h('div', { class: 'fx-frame__body' }, content);
101
102
  const view = h('div', { class: 'fx-frame__view' }, body);
102
- 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');
103
104
  edgeFade(body, { target: view, edges: ['bottom', 'left', 'right'] });
104
105
  return { element, body, view, tools, count: (text) => { count.textContent = text ?? ''; } };
105
106
  }
@@ -149,9 +150,9 @@ export function sectionTitle(text, help = null) {
149
150
  }
150
151
 
151
152
  export function notice(text, tone = 'info', title = null) {
152
- 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' },
153
154
  title ? h('strong', {}, title) : null,
154
- h('span', {}, text));
155
+ h('span', {}, text)), 'notice');
155
156
  }
156
157
 
157
158
  // A field that fails a check glows red, like a required field, until the
@@ -159,37 +160,63 @@ export function notice(text, tone = 'info', title = null) {
159
160
  // `on` false clears it. Answers the field.
160
161
  export function fieldError(field, on = true) {
161
162
  if (!on) { field.removeAttribute('aria-invalid'); return field; }
163
+ applyPhoneRule(field, 'fieldError');
162
164
  field.setAttribute('aria-invalid', 'true');
163
165
  field.addEventListener('input', () => field.removeAttribute('aria-invalid'), { once: true });
164
166
  return field;
165
167
  }
166
168
 
167
- 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 } = {}) {
168
172
  const region = document.getElementById('toasts');
169
- 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
+ }
170
186
  region.append(item);
171
- setTimeout(() => item.remove(), 4200);
172
187
  }
173
188
 
174
189
  // A modal built on <dialog>: resolves with the button's value, or null.
175
- export function dialog({ title, body, confirm, tone = 'primary' }) {
190
+ export function dialog({ title, body, confirm, tone = 'primary', choices = null }) {
176
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));
177
201
  const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
178
202
  h('h2', { class: 'fx-dialog__title' }, title),
179
- body,
180
- h('div', { class: 'fx-dialog__actions' },
181
- h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
182
- h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
183
- const element = h('dialog', { class: 'fx-dialog' }, form);
203
+ choiceList ?? body,
204
+ actions);
205
+ const element = applyPhoneRule(h('dialog', { class: 'fx-dialog' }, form), 'dialog');
184
206
  element.addEventListener('close', () => {
185
- const ok = element.returnValue === 'ok';
186
- const data = ok ? new FormData(form) : null;
207
+ const value = element.returnValue;
187
208
  element.remove();
188
- 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);
189
215
  });
216
+ if (choices) element.addEventListener('click', (event) => { if (event.target === element) element.close(); });
190
217
  document.body.append(element);
191
218
  element.showModal();
192
- element.querySelector('input, select, button[value="ok"]')?.focus();
219
+ element.querySelector(choices ? '.fx-sheet__choice' : 'input, select, button[value="ok"]')?.focus();
193
220
  });
194
221
  }
195
222
 
@@ -200,11 +227,11 @@ export function dialog({ title, body, confirm, tone = 'primary' }) {
200
227
  export function settingRow({ label, hint, control, wide = false, tone = null }) {
201
228
  const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
202
229
  const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
203
- 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 },
204
231
  h('div', { class: 'fx-setting__text' },
205
232
  id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
206
233
  hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
207
- h('div', { class: 'fx-setting__control' }, control));
234
+ h('div', { class: 'fx-setting__control' }, control)), 'settingRow');
208
235
  }
209
236
  settingRow.next = 0;
210
237
 
@@ -212,26 +239,126 @@ settingRow.next = 0;
212
239
 
213
240
  let rowMenus = 0;
214
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
+
215
247
  // A row's ⋯ menu, as on the Custom fields page. The menu lines up with the
216
248
  // button's right edge; `align: 'start'` lines it up with its left edge.
217
- // `trigger` swaps the ⋯ icon for the given contents (the sidebar's More).
218
- 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' } = {}) {
219
258
  const id = `fx-row-menu-${++rowMenus}`;
220
- const button = h('button', { class: className, type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, trigger ?? lucide(h, 'ellipsis'));
221
- const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
222
- panel.addEventListener('toggle', (event) => {
223
- button.setAttribute('aria-expanded', String(event.newState === 'open'));
224
- if (event.newState !== 'open') return;
225
- const close = () => panel.hidePopover();
226
- panel.replaceChildren(...items.map((entry) => (entry === '-'
227
- ? h('div', { class: 'fx-pop__sep' })
228
- : h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
229
- 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() {
230
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
+ }
231
310
  panel.style.top = `${Math.round(at.bottom + 6)}px`;
232
311
  const left = align === 'start' ? at.left : at.right - panel.offsetWidth;
233
- 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();
234
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
+
235
362
  return [button, panel];
236
363
  }
237
364
 
@@ -254,32 +381,80 @@ function inPlace(event) {
254
381
  export function tileCard({ href, mark, title, status, blurb, meta, action = 'Manage', row = false }) {
255
382
  const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, lucide(h, mark)) : mark;
256
383
  const pill = status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null;
384
+ // The name is cut with "…" on a narrow row; its title is the full name.
385
+ // The › shows only on a phone, where every card is one row.
386
+ const name = h('h3', { title }, title);
387
+ const go = h('span', { class: 'fx-tile__go', 'aria-hidden': 'true' }, lucide(h, 'chevron-right'));
257
388
  if (row) {
258
- return h('a', { class: 'fx-tile', href, onclick: inPlace },
389
+ return applyPhoneRule(h('a', { class: 'fx-tile', href, onclick: inPlace },
259
390
  logo,
260
- h('h3', {}, title),
261
- pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null));
391
+ name,
392
+ pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null),
393
+ go), 'tileCard');
262
394
  }
263
- return h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
264
- h('header', { class: 'fx-int-card__head' }, logo, h('h3', {}, title), pill),
395
+ return applyPhoneRule(h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
396
+ h('header', { class: 'fx-int-card__head' }, logo, name, pill, go),
265
397
  blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
266
- h('span', { class: 'fx-int-card__btn' }, action));
398
+ h('span', { class: 'fx-int-card__btn' }, action)), 'tileCard');
267
399
  }
268
400
 
269
401
  // A titled group of one-line cards, three columns where they fit, with "Show all" at the right
270
402
  // when `href` is given (Claude's Customize page).
271
403
  export function tileSection(title, cards, { href, more = 'Show all' } = {}) {
272
- return h('section', { class: 'fx-tiles' },
404
+ return applyPhoneRule(h('section', { class: 'fx-tiles' },
273
405
  h('header', { class: 'fx-tiles__head' },
274
406
  h('h2', {}, title),
275
407
  href ? h('a', { class: 'fx-tiles__more', href, onclick: inPlace }, more, lucide(h, 'chevron-right')) : null),
276
- h('div', { class: 'fx-tiles__grid' }, cards));
408
+ h('div', { class: 'fx-tiles__grid' }, cards)), 'tileSection');
409
+ }
410
+
411
+ // A dialog that fills the screen has no pixel outside it, so an accessibility
412
+ // check cannot tell it is modal and reads the page underneath. While one is
413
+ // open, every other top-level node is inert. The attribute is what the check
414
+ // reads; the browser's own modal behaviour is unchanged. `ignore` is a dialog
415
+ // that is closing: a browser may still mark it open while it fires close.
416
+ function shieldBehindDialogs(ignore = null) {
417
+ const top = [...document.querySelectorAll('dialog[open]')].filter((el) => el !== ignore).at(-1) ?? null;
418
+ for (const child of document.body.children) {
419
+ if (top && child !== top) child.setAttribute('inert', '');
420
+ else child.removeAttribute('inert');
421
+ }
422
+ }
423
+ // Node tests import this module and have no HTMLDialogElement. The wrap only
424
+ // matters in the browser. showModal is patched because nothing else announces
425
+ // that a dialog opened. close() drops the shield for a dialog closed in
426
+ // script. A form with method="dialog", and Escape, close the dialog inside
427
+ // the browser and never call close(). Chrome then fires toggle, and close,
428
+ // on a later turn. Until one of those runs, the inert attribute would stay
429
+ // and no click would land (an invoice Delete did this, until a reload).
430
+ // close does not bubble, so the listeners are on the capture path. A popover
431
+ // toggle is ignored: only a dialog clears the shield.
432
+ if (typeof HTMLDialogElement !== 'undefined') {
433
+ const openDialog = HTMLDialogElement.prototype.showModal;
434
+ const closeDialog = HTMLDialogElement.prototype.close;
435
+ HTMLDialogElement.prototype.showModal = function showModal() {
436
+ const result = openDialog.call(this);
437
+ shieldBehindDialogs();
438
+ return result;
439
+ };
440
+ HTMLDialogElement.prototype.close = function close(...args) {
441
+ const result = closeDialog.apply(this, args);
442
+ shieldBehindDialogs(this);
443
+ return result;
444
+ };
445
+ const onDialogClosed = (event) => {
446
+ if (event.target instanceof HTMLDialogElement) shieldBehindDialogs(event.target);
447
+ };
448
+ document.addEventListener('close', onDialogClosed, true);
449
+ document.addEventListener('toggle', (event) => {
450
+ if (event.newState === 'closed') onDialogClosed(event);
451
+ }, true);
277
452
  }
278
453
 
279
454
  // The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
280
455
  export function crumbs(...trail) {
281
456
  const here = trail.pop();
282
- return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
457
+ return applyPhoneRule(h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
283
458
  ...trail.flatMap(([href, label]) => [h('a', { href, onclick: inPlace }, label), h('span', { 'aria-hidden': 'true' }, '›')]),
284
- h('span', {}, here));
459
+ h('span', {}, here)), 'crumbs');
285
460
  }
@@ -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