@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.
- package/README.md +1 -0
- package/VISION.md +10 -10
- package/npm-shrinkwrap.json +104 -2
- package/package.json +6 -2
- package/server/api/invoicing.js +56 -16
- package/server/app.js +4 -2
- package/server/auth/people.js +5 -3
- package/server/auth/routes.js +4 -1
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +40 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +76 -22
- package/server/integrations/call-guard.js +21 -4
- package/server/integrations/sentry/index.js +14 -7
- package/server/integrations/xero/client.js +12 -2
- package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/routes.js +29 -5
- package/server/integrations/xero/runtime.js +5 -0
- package/server/integrations/xero/usage.js +74 -0
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +110 -27
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +54 -2
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +353 -45
- package/server/ui/invoicing.js +286 -64
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +14 -4
- package/server/ui/jobs.js +156 -56
- package/server/ui/kit.js +212 -51
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +726 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +41 -0
- package/server/ui/page.js +110 -5
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +19 -18
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +6 -5
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- package/server/ui/xero-import.js +48 -19
- package/server/ui/xero-usage-model.js +106 -0
- 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,
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
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
|
|
75
|
-
|
|
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 }),
|
|
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
|
-
|
|
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', {
|
|
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
|
-
|
|
175
|
-
|
|
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
|
|
180
|
-
const data = ok ? new FormData(form) : null;
|
|
207
|
+
const value = element.returnValue;
|
|
181
208
|
element.remove();
|
|
182
|
-
|
|
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
|
-
// `
|
|
212
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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(
|
|
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
|
}
|
package/server/ui/list-views.js
CHANGED
|
@@ -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 {
|
|
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)
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|