@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/sidecar.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// The sidecar, as in ClickUp: a panel at the right of a page or a dialog.
|
|
2
2
|
// The Custom fields manager opens it over its table (`overlay`); a job's
|
|
3
|
-
// panel keeps it as its right column, with tabs (Activity,
|
|
4
|
-
// it takes no room, so the main area gets the full width.
|
|
3
|
+
// panel keeps it as its right column, with tabs (Activity, Fix). Closed,
|
|
4
|
+
// it takes no room, so the main area gets the full width. `phone: 'page'`
|
|
5
|
+
// opens that panel as a full page on a phone: the tabs sit at the top of
|
|
6
|
+
// the record and the record bar (`recordBar`) carries back, the title and
|
|
7
|
+
// the next record. The main action stays in the caller's foot.
|
|
5
8
|
//
|
|
6
|
-
// sidecar(h, { label, overlay, escape, tabs, onChange }) answers
|
|
9
|
+
// sidecar(h, { label, overlay, escape, tabs, onChange, phone }) answers
|
|
7
10
|
// { element, isOpen, open, show, close, tab, setTabs }:
|
|
8
11
|
// open(id) selects tab `id` (or the first) and draws its `render()`
|
|
9
12
|
// show(node) draws one node with no tabs (the overlay's own form)
|
|
@@ -13,14 +16,34 @@
|
|
|
13
16
|
// `onChange(id)` hears each open (the tab id) and close (null).
|
|
14
17
|
|
|
15
18
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
19
|
+
import { applyPhoneRule, isPhone, WIDTHS } from './mobile.js';
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
const CHEVRON_LEFT = 'M14.5 6l-6 6 6 6';
|
|
22
|
+
const CHEVRON_RIGHT = 'M9.5 6l6 6-6 6';
|
|
23
|
+
|
|
24
|
+
function glyph(button, path) {
|
|
25
|
+
button.innerHTML = `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
26
|
+
return button;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function sidecar(h, { label, overlay = false, escape = false, tabs = [], onChange = null, phone = null } = {}) {
|
|
30
|
+
const element = applyPhoneRule(h('aside', { class: `fx-sidecar${overlay ? ' fx-sidecar--over' : ''}`, 'aria-label': label, hidden: true }), 'sidecar');
|
|
19
31
|
let list = tabs;
|
|
20
32
|
let current = null;
|
|
33
|
+
let drawn = false;
|
|
34
|
+
if (phone === 'page') element.dataset.phone = 'page';
|
|
21
35
|
|
|
22
36
|
const closeButton = () => h('button', { class: 'fx-sidecar__close', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: () => api.close() }, lucide(h, 'x'));
|
|
23
37
|
|
|
38
|
+
// On a phone page the tab strip is the record's top tabs, so it lives on
|
|
39
|
+
// the record (the parent) and stays put while the record scrolls. The
|
|
40
|
+
// body stays in the sidecar, which is what the phone suite measures.
|
|
41
|
+
function parkHead(head) {
|
|
42
|
+
const grid = element.parentElement;
|
|
43
|
+
if (element.classList.contains('fx-sidecar--page') && grid) grid.prepend(head);
|
|
44
|
+
else element.prepend(head);
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
function drawTabs() {
|
|
25
48
|
const head = h('header', { class: 'fx-sidecar__head' },
|
|
26
49
|
h('div', { class: 'fx-sidecar__tabs', role: 'tablist', 'aria-label': label },
|
|
@@ -30,7 +53,10 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
|
|
|
30
53
|
}, lucide(h, t.icon), h('span', {}, t.label), t.mark ? h('span', { class: 'fx-sidecar__mark', 'aria-hidden': 'true' }) : null))),
|
|
31
54
|
closeButton());
|
|
32
55
|
const tab = list.find((t) => t.id === current);
|
|
33
|
-
|
|
56
|
+
const body = h('div', { class: 'fx-sidecar__body', role: 'tabpanel' }, tab?.render?.() ?? null);
|
|
57
|
+
element.parentElement?.querySelector(':scope > .fx-sidecar__head')?.remove();
|
|
58
|
+
element.replaceChildren(body);
|
|
59
|
+
parkHead(head);
|
|
34
60
|
}
|
|
35
61
|
|
|
36
62
|
const api = {
|
|
@@ -41,18 +67,27 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
|
|
|
41
67
|
current = list.find((t) => t.id === id)?.id ?? list[0]?.id ?? null;
|
|
42
68
|
drawTabs();
|
|
43
69
|
element.hidden = false;
|
|
70
|
+
// The first open leaves the record where it is. A later tab brings
|
|
71
|
+
// its panel up under the sticky tabs.
|
|
72
|
+
if (drawn && element.classList.contains('fx-sidecar--page')) {
|
|
73
|
+
element.querySelector('.fx-sidecar__body')?.scrollIntoView({ block: 'start' });
|
|
74
|
+
}
|
|
75
|
+
drawn = true;
|
|
44
76
|
onChange?.(current);
|
|
45
77
|
},
|
|
46
78
|
show(node) {
|
|
47
79
|
current = null;
|
|
80
|
+
element.parentElement?.querySelector(':scope > .fx-sidecar__head')?.remove();
|
|
48
81
|
element.replaceChildren(node);
|
|
49
82
|
element.hidden = false;
|
|
50
83
|
},
|
|
51
84
|
close() {
|
|
52
85
|
if (element.hidden) return;
|
|
53
86
|
element.hidden = true;
|
|
87
|
+
element.parentElement?.querySelector(':scope > .fx-sidecar__head')?.remove();
|
|
54
88
|
element.replaceChildren();
|
|
55
89
|
current = null;
|
|
90
|
+
drawn = false;
|
|
56
91
|
onChange?.(null);
|
|
57
92
|
},
|
|
58
93
|
// New tabs (a tab's data came in): an open tab draws again.
|
|
@@ -63,6 +98,15 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
|
|
|
63
98
|
closeButton,
|
|
64
99
|
};
|
|
65
100
|
|
|
101
|
+
if (phone === 'page') {
|
|
102
|
+
const sync = () => {
|
|
103
|
+
element.classList.toggle('fx-sidecar--page', isPhone());
|
|
104
|
+
if (!element.hidden && current) drawTabs();
|
|
105
|
+
};
|
|
106
|
+
sync();
|
|
107
|
+
if (typeof matchMedia === 'function') matchMedia(WIDTHS.phone).addEventListener('change', sync);
|
|
108
|
+
}
|
|
109
|
+
|
|
66
110
|
if (escape) {
|
|
67
111
|
document.addEventListener('keydown', (event) => {
|
|
68
112
|
if (event.key === 'Escape' && element.isConnected && !element.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) api.close();
|
|
@@ -70,3 +114,59 @@ export function sidecar(h, { label, overlay = false, escape = false, tabs = [],
|
|
|
70
114
|
}
|
|
71
115
|
return api;
|
|
72
116
|
}
|
|
117
|
+
|
|
118
|
+
// The bar at the top of a record on a phone: ← back, the title, ‹ › for the
|
|
119
|
+
// previous and next record, and an optional ⋯ menu. Hidden above phone
|
|
120
|
+
// width (flow.css), so a desk record keeps its own bar. `prev` and `next`
|
|
121
|
+
// are { label, disabled, onclick }. `setSteps` and `setMenu` update them
|
|
122
|
+
// after the list arrives.
|
|
123
|
+
export function recordBar(h, { title = '', onBack, prev = null, next = null, menu = null, label = 'Record' } = {}) {
|
|
124
|
+
const back = glyph(h('button', { class: 'fx-record__back', type: 'button', 'aria-label': 'Back', title: 'Back', onclick: onBack }), CHEVRON_LEFT);
|
|
125
|
+
const titleNode = h('h2', { class: 'fx-record__title', title }, title);
|
|
126
|
+
const step = (path) => glyph(h('button', { class: 'fx-record__step', type: 'button' }), path);
|
|
127
|
+
const prevBtn = step(CHEVRON_LEFT);
|
|
128
|
+
const nextBtn = step(CHEVRON_RIGHT);
|
|
129
|
+
const end = h('div', { class: 'fx-record__end' }, prevBtn, nextBtn);
|
|
130
|
+
const apply = (button, spec, fallback) => {
|
|
131
|
+
const name = spec?.label ?? fallback;
|
|
132
|
+
button.setAttribute('aria-label', name);
|
|
133
|
+
button.title = name;
|
|
134
|
+
button.disabled = !spec || Boolean(spec.disabled);
|
|
135
|
+
button.onclick = spec?.onclick ?? null;
|
|
136
|
+
};
|
|
137
|
+
apply(prevBtn, prev, 'Previous');
|
|
138
|
+
apply(nextBtn, next, 'Next');
|
|
139
|
+
const element = h('header', { class: 'fx-record', 'aria-label': label }, back, titleNode, end);
|
|
140
|
+
element.setSteps = (nextPrev, nextNext) => {
|
|
141
|
+
apply(prevBtn, nextPrev, 'Previous');
|
|
142
|
+
apply(nextBtn, nextNext, 'Next');
|
|
143
|
+
};
|
|
144
|
+
element.setMenu = (node) => {
|
|
145
|
+
end.querySelector('.fx-record__menu')?.remove();
|
|
146
|
+
if (node) end.append(node);
|
|
147
|
+
};
|
|
148
|
+
if (menu) element.setMenu(menu);
|
|
149
|
+
return element;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Keeps `pick()` in `foot` while the window is a phone, and back in `home`
|
|
153
|
+
// (the header's actions) otherwise. Returns the function to call again when
|
|
154
|
+
// the primary action changes (Save appears, a send arrives).
|
|
155
|
+
export function watchPhonePrimary(home, foot, pick) {
|
|
156
|
+
let parked = null;
|
|
157
|
+
const apply = () => {
|
|
158
|
+
const next = isPhone() ? (pick() ?? null) : null;
|
|
159
|
+
if (parked && parked !== next && home) home.append(parked);
|
|
160
|
+
parked = next;
|
|
161
|
+
if (parked) {
|
|
162
|
+
foot.hidden = false;
|
|
163
|
+
if (parked.parentElement !== foot) foot.replaceChildren(parked);
|
|
164
|
+
} else {
|
|
165
|
+
foot.replaceChildren();
|
|
166
|
+
foot.hidden = true;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
apply();
|
|
170
|
+
if (typeof matchMedia === 'function') matchMedia(WIDTHS.phone).addEventListener('change', apply);
|
|
171
|
+
return apply;
|
|
172
|
+
}
|
package/server/ui/view-bar.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// first the field a new row starts on
|
|
23
23
|
// empty the words when no filter is on
|
|
24
24
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
25
|
+
import { applyPhoneRule } from './mobile.js';
|
|
25
26
|
import { DATE_VALUES, dateValueIsOn, filterCount, KIND_ICONS, OPERATORS } from '/assets/ui/job-view.js';
|
|
26
27
|
|
|
27
28
|
const DAY_BETWEEN = /^between:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/;
|
|
@@ -59,17 +60,22 @@ export function viewBar(h) {
|
|
|
59
60
|
const panel = h('div', { class: `fx-pop${rows ? ' fx-pop--rows' : ''}`, id, popover: 'auto', role: rows ? 'dialog' : 'menu' });
|
|
60
61
|
const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': rows ? 'dialog' : 'menu', 'aria-expanded': 'false' });
|
|
61
62
|
const clear = h('button', { type: 'button', class: 'fx-chip__clear', 'aria-label': clearLabel, title: clearLabel, hidden: true }, lucide(h, 'x'));
|
|
62
|
-
const wrap = h('span', { class: 'fx-chipset' }, button, clear);
|
|
63
|
+
const wrap = applyPhoneRule(h('span', { class: 'fx-chipset' }, button, clear), 'viewBar');
|
|
63
64
|
let onClear = null;
|
|
64
65
|
clear.addEventListener('click', () => onClear?.());
|
|
65
|
-
|
|
66
|
+
// Place again after the rows draw. A filter row is wider than the empty
|
|
67
|
+
// panel, and the panel is measured once it is open; without this a button
|
|
68
|
+
// at the right (Invoices) leaves the value and the bin off the screen.
|
|
69
|
+
const placeMenu = () => { if (panel.matches(':popover-open')) place(button, panel, 6); };
|
|
70
|
+
const fill = () => {
|
|
71
|
+
panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
|
|
72
|
+
placeMenu();
|
|
73
|
+
requestAnimationFrame(placeMenu);
|
|
74
|
+
};
|
|
66
75
|
panel.addEventListener('toggle', (event) => {
|
|
67
76
|
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
68
77
|
if (event.newState !== 'open') return;
|
|
69
78
|
fill();
|
|
70
|
-
const at = button.getBoundingClientRect();
|
|
71
|
-
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
72
|
-
panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
73
79
|
});
|
|
74
80
|
return {
|
|
75
81
|
node: [wrap, panel],
|
|
@@ -144,11 +150,16 @@ export function viewBar(h) {
|
|
|
144
150
|
});
|
|
145
151
|
return [button, pop];
|
|
146
152
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
153
|
+
// Keep a popover on the screen: at the control's left edge, shifted left
|
|
154
|
+
// when it would run off the right. Called again after a filter row draws,
|
|
155
|
+
// because that row is wider than the empty panel.
|
|
156
|
+
function place(anchor, pop, gap = 4) {
|
|
157
|
+
const at = anchor.getBoundingClientRect();
|
|
158
|
+
const width = pop.offsetWidth;
|
|
159
|
+
const left = Math.max(8, Math.min(at.left, innerWidth - width - 8));
|
|
160
|
+
pop.style.top = `${Math.round(at.bottom + gap)}px`;
|
|
161
|
+
pop.style.left = `${Math.round(left)}px`;
|
|
162
|
+
}
|
|
152
163
|
|
|
153
164
|
// ── Filters ──
|
|
154
165
|
// As ClickUp's: groups of rows of field, operator and value, with a bin on
|
|
@@ -328,7 +339,7 @@ export function viewBar(h) {
|
|
|
328
339
|
export function findBox(h, { label, value = '', oninput }) {
|
|
329
340
|
const input = h('input', { class: 'fx-input fx-input--sm fx-find__input', type: 'search', placeholder: label, 'aria-label': label, value });
|
|
330
341
|
const button = h('button', { type: 'button', class: 'fx-chip fx-find__button', 'aria-label': label, title: label }, lucide(h, 'search'));
|
|
331
|
-
const element = h('span', { class: `fx-find${value ? ' fx-find--open' : ''}` }, button, input);
|
|
342
|
+
const element = applyPhoneRule(h('span', { class: `fx-find${value ? ' fx-find--open' : ''}` }, button, input), 'findBox');
|
|
332
343
|
button.addEventListener('click', () => { element.classList.add('fx-find--open'); input.focus(); });
|
|
333
344
|
input.addEventListener('input', () => oninput(input.value));
|
|
334
345
|
input.addEventListener('blur', () => { if (!input.value) element.classList.remove('fx-find--open'); });
|
package/server/ui/xero-import.js
CHANGED
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
11
11
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
12
12
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
13
|
+
import { applyPhoneRule, assignPhoneSlots, phoneSlot } from './mobile.js';
|
|
14
|
+
|
|
15
|
+
// The list rows use the same phone slots as a column list.
|
|
16
|
+
const phoneAt = (defs) => {
|
|
17
|
+
const columns = assignPhoneSlots(defs);
|
|
18
|
+
return (index) => {
|
|
19
|
+
const slot = phoneSlot(columns[index]);
|
|
20
|
+
return slot ? { 'data-phone-slot': slot } : {};
|
|
21
|
+
};
|
|
22
|
+
};
|
|
13
23
|
|
|
14
24
|
const XERO_STATUS = { DRAFT: 'Draft', SUBMITTED: 'Awaiting approval', AUTHORISED: 'Approved', PAID: 'Paid' };
|
|
15
25
|
const XERO_TONE = { DRAFT: 'neutral', SUBMITTED: 'warn', AUTHORISED: 'info', PAID: 'success' };
|
|
@@ -212,13 +222,23 @@ export async function renderXeroImport(main, ui) {
|
|
|
212
222
|
drawBar();
|
|
213
223
|
} });
|
|
214
224
|
const sure = rows.filter((r) => r.sure);
|
|
225
|
+
const at = phoneAt([
|
|
226
|
+
{ kind: 'text', label: 'Number' },
|
|
227
|
+
{ kind: 'client', label: 'Client', phone: 'title' },
|
|
228
|
+
{ kind: 'date', label: 'Date', phone: 'meta' },
|
|
229
|
+
{ kind: 'total', num: true, label: 'Total' },
|
|
230
|
+
{ kind: 'status', label: 'Match', phone: 'meta' },
|
|
231
|
+
{ kind: 'text', label: 'Flow', phone: 'meta' },
|
|
232
|
+
{ kind: 'date', label: 'Flow date', phone: 'hide' },
|
|
233
|
+
{ kind: 'total', label: 'Flow total', phone: 'hide' },
|
|
234
|
+
]);
|
|
215
235
|
host.replaceChildren(
|
|
216
236
|
h('div', { class: 'fx-xi__bulk' }, icon('list-checks'), h('span', { class: 'fx-xi__bulk-text' }, 'Tick rows, then use the bar below.'),
|
|
217
237
|
canAct && sure.length ? h('button', { class: 'fx-btn fx-btn--sm', type: 'button', onclick: () => {
|
|
218
238
|
for (const r of sure) { ticked.add(r.xeroInvoiceId); boxes.get(r.xeroInvoiceId).checked = true; }
|
|
219
239
|
drawBar();
|
|
220
240
|
} }, icon('check-check'), `Tick ${sure.length} sure`) : null),
|
|
221
|
-
h('div', { class: 'fx-xi__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-xi-list' },
|
|
241
|
+
applyPhoneRule(h('div', { class: 'fx-xi__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-xi-list' },
|
|
222
242
|
h('thead', {}, h('tr', {},
|
|
223
243
|
canAct ? h('th', { class: 'fx-tick-cell' }, all) : null,
|
|
224
244
|
h('th', { colspan: '4' }, xeroMark()), h('th', { class: 'fx-xi-list__mid' }), h('th', { colspan: '3' }, flowMark()))),
|
|
@@ -231,15 +251,15 @@ export async function renderXeroImport(main, ui) {
|
|
|
231
251
|
boxes.set(row.xeroInvoiceId, box);
|
|
232
252
|
return h('tr', {},
|
|
233
253
|
canAct ? h('td', { class: 'fx-tick-cell' }, box) : null,
|
|
234
|
-
h('td', { class: 'fx-num' }, row.number ?? '—'),
|
|
235
|
-
h('td', {}, row.clientName ?? '—'),
|
|
236
|
-
h('td', { class: 'fx-dim' }, shortDate(row.issuedOn)),
|
|
237
|
-
h('td', { class: 'fx-num fx-strong' }, money(row.totalCents)),
|
|
238
|
-
h('td', { class: 'fx-xi-list__mid' }, row.sure ? icon('check-check', 'Sure') : fitIcon(fit), fit.count > 1 ? h('span', { class: 'fx-dim' }, `${fit.count}`) : null),
|
|
239
|
-
h('td', { class: 'fx-num' }, fit.name),
|
|
240
|
-
h('td', { class: 'fx-dim' }, shortDate(fit.date)),
|
|
241
|
-
h('td', { class: 'fx-num fx-strong' }, money(fit.totalCents)));
|
|
242
|
-
})))));
|
|
254
|
+
h('td', { class: 'fx-num', ...at(0) }, row.number ?? '—'),
|
|
255
|
+
h('td', { ...at(1) }, row.clientName ?? '—'),
|
|
256
|
+
h('td', { class: 'fx-dim', ...at(2) }, shortDate(row.issuedOn)),
|
|
257
|
+
h('td', { class: 'fx-num fx-strong', ...at(3) }, money(row.totalCents)),
|
|
258
|
+
h('td', { class: 'fx-xi-list__mid', ...at(4) }, row.sure ? icon('check-check', 'Sure') : fitIcon(fit), fit.count > 1 ? h('span', { class: 'fx-dim' }, `${fit.count}`) : null),
|
|
259
|
+
h('td', { class: 'fx-num', ...at(5) }, fit.name),
|
|
260
|
+
h('td', { class: 'fx-dim', ...at(6) }, shortDate(fit.date)),
|
|
261
|
+
h('td', { class: 'fx-num fx-strong', ...at(7) }, money(fit.totalCents)));
|
|
262
|
+
})))), 'columnTable'));
|
|
243
263
|
drawBar();
|
|
244
264
|
}
|
|
245
265
|
|
|
@@ -259,13 +279,22 @@ export async function renderXeroImport(main, ui) {
|
|
|
259
279
|
} });
|
|
260
280
|
const more = h('div', {});
|
|
261
281
|
const STATE = { linked: ['check', 'In Flow'], invoice_fits: ['receipt', 'A Flow invoice fits'], job_fits: ['briefcase', 'A job fits'], none: ['circle-dashed', 'Not in Flow'] };
|
|
282
|
+
const at = phoneAt([
|
|
283
|
+
{ kind: 'status', label: 'State', phone: 'meta' },
|
|
284
|
+
{ kind: 'text', label: 'Number' },
|
|
285
|
+
{ kind: 'client', label: 'Client', phone: 'title' },
|
|
286
|
+
{ kind: 'date', label: 'Date', phone: 'meta' },
|
|
287
|
+
{ kind: 'total', num: true, label: 'Total' },
|
|
288
|
+
{ kind: 'status', label: 'Xero', phone: 'meta' },
|
|
289
|
+
{ kind: 'text', label: 'Flow', phone: 'hide' },
|
|
290
|
+
]);
|
|
262
291
|
host.replaceChildren(
|
|
263
292
|
h('div', { class: 'fx-xi__bulk' }, icon('search'), search),
|
|
264
|
-
h('div', { class: 'fx-xi__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-xi-list' },
|
|
293
|
+
applyPhoneRule(h('div', { class: 'fx-xi__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-xi-list' },
|
|
265
294
|
h('thead', {}, h('tr', {},
|
|
266
295
|
canAct ? h('th', { class: 'fx-tick-cell' }, all) : null,
|
|
267
296
|
h('th', {}, ''), h('th', {}, 'Number'), h('th', {}, 'Client'), h('th', {}, 'Date'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, xeroMark()), h('th', {}, flowMark()))),
|
|
268
|
-
body)),
|
|
297
|
+
body)), 'columnTable'),
|
|
269
298
|
more);
|
|
270
299
|
let timer = null;
|
|
271
300
|
search.addEventListener('input', () => {
|
|
@@ -295,13 +324,13 @@ export async function renderXeroImport(main, ui) {
|
|
|
295
324
|
} });
|
|
296
325
|
return h('tr', { 'data-state': row.state },
|
|
297
326
|
canAct ? h('td', { class: 'fx-tick-cell' }, box) : null,
|
|
298
|
-
h('td', { class: 'fx-xi-list__mid' }, row.state === 'linked' || row.state === 'none' ? icon(glyph, label) : h('a', { href: '/xero-import', onclick: (event) => ui.go(event), 'aria-label': label, title: label }, icon(glyph))),
|
|
299
|
-
h('td', { class: 'fx-num' }, row.number ?? '—'),
|
|
300
|
-
h('td', {}, row.clientName ?? '—'),
|
|
301
|
-
h('td', { class: 'fx-dim' }, shortDate(row.issuedOn)),
|
|
302
|
-
h('td', { class: 'fx-num fx-strong' }, money(row.totalCents)),
|
|
303
|
-
h('td', {}, pill(row)),
|
|
304
|
-
h('td', { class: 'fx-num' }, row.state === 'linked' ? go(`/invoices/${row.link.invoiceId}`, row.link.number ?? 'Draft') : ''));
|
|
327
|
+
h('td', { class: 'fx-xi-list__mid', ...at(0) }, row.state === 'linked' || row.state === 'none' ? icon(glyph, label) : h('a', { href: '/xero-import', onclick: (event) => ui.go(event), 'aria-label': label, title: label }, icon(glyph))),
|
|
328
|
+
h('td', { class: 'fx-num', ...at(1) }, row.number ?? '—'),
|
|
329
|
+
h('td', { ...at(2) }, row.clientName ?? '—'),
|
|
330
|
+
h('td', { class: 'fx-dim', ...at(3) }, shortDate(row.issuedOn)),
|
|
331
|
+
h('td', { class: 'fx-num fx-strong', ...at(4) }, money(row.totalCents)),
|
|
332
|
+
h('td', { ...at(5) }, pill(row)),
|
|
333
|
+
h('td', { class: 'fx-num', ...at(6) }, row.state === 'linked' ? go(`/invoices/${row.link.invoiceId}`, row.link.number ?? 'Draft') : ''));
|
|
305
334
|
};
|
|
306
335
|
async function load() {
|
|
307
336
|
let page;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Pure numbers for the Xero usage card. No DOM and no database: the server
|
|
2
|
+
// fills it from counters Flow already keeps, and the card draws it.
|
|
3
|
+
|
|
4
|
+
export const USAGE_LANES = Object.freeze(['writes', 'sync', 'reads', 'other']);
|
|
5
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
export function startOfUtcDay(ms) {
|
|
8
|
+
const date = new Date(ms);
|
|
9
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 1,000 a day is Xero's Starter plan; 5,000 is Core and above (docs/XERO.md).
|
|
13
|
+
export function appTier(dayLimit) {
|
|
14
|
+
if (!Number.isFinite(dayLimit) || dayLimit <= 0) return null;
|
|
15
|
+
return dayLimit >= 5000 ? 'Core' : 'Starter';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// How old a time is, as a short chip: "2 min", "3 h", "4 d".
|
|
19
|
+
export function formatAge(at, now) {
|
|
20
|
+
if (!at) return null;
|
|
21
|
+
const ms = Math.max(0, now - new Date(at).getTime());
|
|
22
|
+
const minutes = Math.round(ms / 60_000);
|
|
23
|
+
if (minutes < 1) return 'now';
|
|
24
|
+
if (minutes < 60) return `${minutes} min`;
|
|
25
|
+
const hours = Math.round(ms / 3_600_000);
|
|
26
|
+
if (hours < 24) return `${hours} h`;
|
|
27
|
+
return `${Math.round(ms / DAY_MS)} d`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatWhen(at) {
|
|
31
|
+
if (!at) return null;
|
|
32
|
+
return new Date(at).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function emptyLanes() {
|
|
36
|
+
return { writes: 0, sync: 0, reads: 0, other: 0 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function addLane(target, lane, n) {
|
|
40
|
+
if (USAGE_LANES.includes(lane)) target[lane] += n;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// `hourRows` are { hour, lane, n } for today. `dayRows` are { day, n } over
|
|
44
|
+
// the last 7 UTC days. `limitRows` are 429s { at, problem, lane, task }.
|
|
45
|
+
export function assembleUsage({
|
|
46
|
+
hourRows = [],
|
|
47
|
+
dayRows = [],
|
|
48
|
+
taskRows = [],
|
|
49
|
+
limitRows = [],
|
|
50
|
+
governor,
|
|
51
|
+
warningFraction,
|
|
52
|
+
now = Date.now(),
|
|
53
|
+
}) {
|
|
54
|
+
const dayStart = startOfUtcDay(now);
|
|
55
|
+
const hours = Array.from({ length: 24 }, (_, index) => ({
|
|
56
|
+
hour: new Date(dayStart + index * 3_600_000).toISOString(),
|
|
57
|
+
...emptyLanes(),
|
|
58
|
+
}));
|
|
59
|
+
const lanes = emptyLanes();
|
|
60
|
+
for (const row of hourRows) {
|
|
61
|
+
const index = Math.round((Date.parse(row.hour) - dayStart) / 3_600_000);
|
|
62
|
+
const n = Number(row.n) || 0;
|
|
63
|
+
if (index >= 0 && index < 24) addLane(hours[index], row.lane, n);
|
|
64
|
+
addLane(lanes, row.lane, n);
|
|
65
|
+
}
|
|
66
|
+
const logged = USAGE_LANES.reduce((sum, lane) => sum + lanes[lane], 0);
|
|
67
|
+
const dayLimit = Number(governor?.dayLimit) || 0;
|
|
68
|
+
const used = Math.max(Number(governor?.dayCount) || 0, logged);
|
|
69
|
+
const observed = governor?.observedDayRemaining;
|
|
70
|
+
const left = Number.isFinite(observed) ? observed : Math.max(0, dayLimit - used);
|
|
71
|
+
const fraction = Number.isFinite(warningFraction) ? warningFraction : 0.7;
|
|
72
|
+
const line = dayLimit * fraction;
|
|
73
|
+
|
|
74
|
+
const byDay = new Map(dayRows.map((row) => [String(row.day).slice(0, 10), Number(row.n) || 0]));
|
|
75
|
+
const weekStart = dayStart - 6 * DAY_MS;
|
|
76
|
+
let warnedDays = 0;
|
|
77
|
+
const days = Array.from({ length: 7 }, (_, index) => {
|
|
78
|
+
const day = new Date(weekStart + index * DAY_MS).toISOString().slice(0, 10);
|
|
79
|
+
const total = byDay.get(day) ?? 0;
|
|
80
|
+
const warning = dayLimit > 0 && total >= line;
|
|
81
|
+
if (warning) warnedDays += 1;
|
|
82
|
+
return { day, total, warning, over: dayLimit > 0 && total >= dayLimit };
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
used,
|
|
87
|
+
dayLimit,
|
|
88
|
+
left,
|
|
89
|
+
dayLimitSource: governor?.dayLimitSource ?? 'setting',
|
|
90
|
+
warningFraction: fraction,
|
|
91
|
+
warning: dayLimit > 0 && used >= line,
|
|
92
|
+
tier: appTier(dayLimit),
|
|
93
|
+
lanes,
|
|
94
|
+
hours,
|
|
95
|
+
days,
|
|
96
|
+
warnedDays,
|
|
97
|
+
suggestTier: warnedDays >= 2,
|
|
98
|
+
tasks: taskRows.map((row) => ({ task: row.task, calls: Number(row.n) || 0 })),
|
|
99
|
+
limited: limitRows.map((row) => ({
|
|
100
|
+
at: row.at ? new Date(row.at).toISOString() : null,
|
|
101
|
+
problem: row.problem || null,
|
|
102
|
+
lane: USAGE_LANES.includes(row.lane) ? row.lane : null,
|
|
103
|
+
task: row.task || null,
|
|
104
|
+
})),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// The Xero card's usage picture (Integrations › Xero). Three levels: the ring
|
|
2
|
+
// and chips always, today open, the week closed. Numbers come from
|
|
3
|
+
// GET /api/xero/status. No sentence is needed to read the first level.
|
|
4
|
+
import { h, notice } from '/assets/ui/kit.js';
|
|
5
|
+
import { svg } from '/assets/ui/view-bar.js';
|
|
6
|
+
import { USAGE_LANES, formatAge, formatWhen } from '/assets/ui/xero-usage-model.js';
|
|
7
|
+
|
|
8
|
+
const LANE_LABEL = { writes: 'Writes', sync: 'Sync', reads: 'Reads', other: 'Other' };
|
|
9
|
+
const num = new Intl.NumberFormat('en-AU');
|
|
10
|
+
const HOUR = 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
function lanePart(count, whole) {
|
|
13
|
+
if (!whole) return '0%';
|
|
14
|
+
return `${(count / whole) * 100}%`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ring(usage) {
|
|
18
|
+
const pct = usage.dayLimit > 0 ? Math.min(100, Math.round((usage.used / usage.dayLimit) * 1000) / 10) : 0;
|
|
19
|
+
const label = `${num.format(usage.used)} of ${num.format(usage.dayLimit)}`;
|
|
20
|
+
return h('div', { class: 'fx-xu-ring', role: 'img', 'aria-label': label },
|
|
21
|
+
svg(`<svg viewBox="0 0 36 36" aria-hidden="true"><circle class="fx-xu-ring__track" cx="18" cy="18" r="15.5" pathLength="100"/><circle class="fx-xu-ring__used" cx="18" cy="18" r="15.5" pathLength="100" stroke-dasharray="${pct} 100"/></svg>`),
|
|
22
|
+
h('div', { class: 'fx-xu-ring__label' },
|
|
23
|
+
h('span', { class: 'fx-xu-ring__num' }, num.format(usage.used)),
|
|
24
|
+
h('span', { class: 'fx-xu-ring__of' }, num.format(usage.dayLimit))));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function chips(status, now) {
|
|
28
|
+
const stopped = status.calls?.stopped;
|
|
29
|
+
const connection = !status.connected
|
|
30
|
+
? ['mute', 'Off']
|
|
31
|
+
: stopped && status.calls.all
|
|
32
|
+
? ['danger', 'Stopped']
|
|
33
|
+
: stopped
|
|
34
|
+
? ['warn', 'Limited']
|
|
35
|
+
: ['success', 'Connected'];
|
|
36
|
+
const age = formatAge(status.freshness?.copyAt, now);
|
|
37
|
+
return h('div', { class: 'fx-xu__chips' },
|
|
38
|
+
h('span', { class: 'fx-status', 'data-tone': connection[0] }, connection[1]),
|
|
39
|
+
status.usage.tier ? h('span', { class: 'fx-chip fx-chip--static' }, status.usage.tier) : null,
|
|
40
|
+
h('span', { class: 'fx-chip fx-chip--static', title: 'Copy', 'aria-label': age ? `Copy ${age}` : 'Copy' }, age ?? '—'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function legend() {
|
|
44
|
+
return h('div', { class: 'fx-xu-legend' }, USAGE_LANES.map((lane) => h('span', {},
|
|
45
|
+
h('i', { class: 'fx-xu-lane', 'data-lane': lane, 'aria-hidden': 'true' }),
|
|
46
|
+
LANE_LABEL[lane])));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hourChart(usage) {
|
|
50
|
+
const peak = Math.max(1, ...usage.hours.map((hour) => USAGE_LANES.reduce((sum, lane) => sum + hour[lane], 0)));
|
|
51
|
+
return h('div', { class: 'fx-xu-hours', role: 'img', 'aria-label': 'Calls by hour' }, usage.hours.map((hour) => {
|
|
52
|
+
const total = USAGE_LANES.reduce((sum, lane) => sum + hour[lane], 0);
|
|
53
|
+
const stack = h('span', { class: 'fx-xu-hour__stack', style: { '--h': `${(total / peak) * 100}%` } },
|
|
54
|
+
USAGE_LANES.map((lane) => (hour[lane]
|
|
55
|
+
? h('i', { class: 'fx-xu-lane', 'data-lane': lane, style: { '--part': lanePart(hour[lane], total) } })
|
|
56
|
+
: null)));
|
|
57
|
+
const when = new Date(hour.hour);
|
|
58
|
+
stack.title = `${when.getUTCHours()}:00 · ${total}`;
|
|
59
|
+
return h('span', { class: 'fx-xu-hour' }, stack);
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function budgetBar(usage) {
|
|
64
|
+
return h('div', { class: 'fx-xu-budget', role: 'img', 'aria-label': 'Budget by lane' },
|
|
65
|
+
USAGE_LANES.map((lane) => h('i', {
|
|
66
|
+
class: 'fx-xu-lane',
|
|
67
|
+
'data-lane': lane,
|
|
68
|
+
style: { '--part': lanePart(usage.lanes[lane], usage.dayLimit) },
|
|
69
|
+
title: `${LANE_LABEL[lane]} ${num.format(usage.lanes[lane])}`,
|
|
70
|
+
})));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function dot(label, tone, title) {
|
|
74
|
+
return h('span', { class: 'fx-xu-dot', 'data-tone': tone, title: title || label }, label);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function freshnessDots(freshness, limited, now) {
|
|
78
|
+
const tone = (at, freshFor) => {
|
|
79
|
+
if (!at) return 'mute';
|
|
80
|
+
return now - new Date(at).getTime() <= freshFor ? 'success' : 'warn';
|
|
81
|
+
};
|
|
82
|
+
const recentLimit = limited.some((row) => row.at && now - new Date(row.at).getTime() <= 24 * HOUR);
|
|
83
|
+
return h('div', { class: 'fx-xu-dots' },
|
|
84
|
+
dot('Webhook', tone(freshness?.webhookAt, 24 * HOUR), formatWhen(freshness?.webhookAt) ?? 'Webhook'),
|
|
85
|
+
dot('Poll', tone(freshness?.pollAt, 2 * HOUR), formatWhen(freshness?.pollAt) ?? 'Poll'),
|
|
86
|
+
dot('Nightly', tone(freshness?.nightlyAt, 26 * HOUR), formatWhen(freshness?.nightlyAt) ?? 'Nightly'),
|
|
87
|
+
dot('429', recentLimit ? 'danger' : 'success', recentLimit ? `${limited.length}` : '429'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function dayChart(usage) {
|
|
91
|
+
return h('div', { class: 'fx-xu-days', style: { '--warn': String(usage.warningFraction) }, role: 'img', 'aria-label': 'Calls over 7 days' },
|
|
92
|
+
h('span', { class: 'fx-xu-days__line', title: 'Warning' }),
|
|
93
|
+
usage.days.map((day) => {
|
|
94
|
+
const height = usage.dayLimit > 0 ? Math.min(100, (day.total / usage.dayLimit) * 100) : 0;
|
|
95
|
+
return h('span', { class: 'fx-xu-day', 'data-warning': String(day.warning), 'data-over': String(day.over), title: `${day.day} · ${num.format(day.total)}` },
|
|
96
|
+
h('span', { class: 'fx-xu-day__bar' }, h('b', { style: { '--h': `${height}%` } })),
|
|
97
|
+
h('span', {}, day.day.slice(8)));
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function rows(items, empty) {
|
|
102
|
+
if (!items.length) return h('p', { class: 'fx-dim' }, empty);
|
|
103
|
+
return h('div', { class: 'fx-xu-rows' }, items.map((item) => h('div', { class: 'fx-xu-row' },
|
|
104
|
+
h('span', {}, item.label),
|
|
105
|
+
h('b', {}, item.value))));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The picture for one connected organisation. `status` is GET /api/xero/status.
|
|
109
|
+
export function xeroUsage(status, now = Date.now()) {
|
|
110
|
+
const usage = status?.usage;
|
|
111
|
+
if (!usage) return null;
|
|
112
|
+
const tone = usage.used >= usage.dayLimit && usage.dayLimit > 0 ? 'danger' : usage.warning ? 'warn' : 'ok';
|
|
113
|
+
return h('section', { class: 'fx-xu', 'data-tone': tone, 'aria-label': 'Usage' },
|
|
114
|
+
h('div', { class: 'fx-xu__top' }, ring(usage), chips(status, now)),
|
|
115
|
+
h('details', { class: 'fx-xu__fold', open: true },
|
|
116
|
+
h('summary', {}, 'Today'),
|
|
117
|
+
legend(),
|
|
118
|
+
hourChart(usage),
|
|
119
|
+
budgetBar(usage),
|
|
120
|
+
freshnessDots(status.freshness, usage.limited, now)),
|
|
121
|
+
h('details', { class: 'fx-xu__fold' },
|
|
122
|
+
h('summary', {}, 'Detail'),
|
|
123
|
+
dayChart(usage),
|
|
124
|
+
rows(usage.tasks.map((task) => ({ label: task.task, value: num.format(task.calls) })), '—'),
|
|
125
|
+
rows(usage.limited.map((row) => ({
|
|
126
|
+
label: row.problem || '429',
|
|
127
|
+
value: formatWhen(row.at) ?? '—',
|
|
128
|
+
})), '—'),
|
|
129
|
+
h('div', { class: 'fx-xu-row' },
|
|
130
|
+
h('span', {}, 'Token'),
|
|
131
|
+
h('b', {}, formatWhen(status.freshness?.tokenRefreshedAt) ?? '—')),
|
|
132
|
+
usage.suggestTier ? notice(`Calls passed the warning on ${usage.warnedDays} days. A higher Xero tier raises the day's limit.`, 'warn', 'Tier') : null));
|
|
133
|
+
}
|