@arcforgelabs/flow 2026.10.46 → 2026.10.48-1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/VISION.md +10 -10
- package/npm-shrinkwrap.json +11 -11
- package/package.json +1 -1
- package/server/api/invoicing.js +89 -22
- package/server/auth/people.js +3 -1
- package/server/auth/routes.js +4 -1
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +93 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +141 -25
- package/server/db/doctor.js +1 -1
- package/server/db/index.js +19 -0
- package/server/db/migrations/0021_flow_schema.sql +68 -0
- package/server/db/migrations/0022_contacts.sql +11 -0
- package/server/db/migrations/meta/0021_snapshot.json +4377 -0
- package/server/db/migrations/meta/0022_snapshot.json +4446 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +15 -0
- package/server/integrations/port.js +2 -0
- package/server/integrations/schema.js +3 -2
- package/server/integrations/seazona/sweep.js +11 -8
- package/server/integrations/sentry/index.js +14 -7
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/catalogue.js +82 -14
- package/server/integrations/xero/client.js +37 -0
- package/server/integrations/xero/create-contact.js +126 -0
- package/server/integrations/xero/delete-invoice.js +104 -0
- package/server/integrations/xero/drafts.js +19 -8
- package/server/integrations/xero/index.js +9 -0
- package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
- package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/routes.js +21 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/ui/alerts.js +2 -1
- package/server/ui/app.js +113 -28
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/contacts.js +260 -0
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +275 -24
- package/server/ui/invoicing.js +374 -70
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +3 -1
- package/server/ui/jobs.js +150 -19
- package/server/ui/kit.js +220 -45
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +729 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +44 -0
- 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 +18 -17
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +2 -1
- 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/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
|
-
|
|
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', {
|
|
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
|
-
|
|
181
|
-
|
|
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
|
|
186
|
-
const data = ok ? new FormData(form) : null;
|
|
207
|
+
const value = element.returnValue;
|
|
187
208
|
element.remove();
|
|
188
|
-
|
|
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
|
-
// `
|
|
218
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
}
|
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
|