@arcforgelabs/flow 2026.10.41 → 2026.10.43
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +15 -5
- package/server/core/invoice-rules.js +49 -8
- package/server/core/sending.js +33 -5
- package/server/integrations/sentry/index.js +9 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +74 -29
- package/server/ui/automations.js +9 -3
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +77 -37
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoice-rules.js +38 -13
- package/server/ui/invoicing-settings.js +69 -35
- package/server/ui/invoicing.js +17 -51
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/jobs.js +88 -32
- package/server/ui/monitoring.js +1 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- package/server/ui/routes.js +1 -1
- package/server/ui/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/xero-import.js +7 -10
package/server/ui/tip.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Tips: every control that shows only an icon says its name on hover and on
|
|
2
|
+
// keyboard focus, in one small tip beside it. One listener on the document
|
|
3
|
+
// serves the whole app, so a new icon button needs only a name:
|
|
4
|
+
//
|
|
5
|
+
// data-tip="Clear" the tip, when it differs from the spoken name
|
|
6
|
+
// aria-label="Close" an icon-only control's name is its tip
|
|
7
|
+
// title="Search" taken over as the tip (no second, native tip)
|
|
8
|
+
//
|
|
9
|
+
// A control with words on it shows no tip. Clerk's own components keep their
|
|
10
|
+
// own tips. The tip floats on <body>, so a scrolling list cannot cut it off.
|
|
11
|
+
|
|
12
|
+
const INTERACTIVE = 'button, a[href], [role="button"], [role="tab"], [role="radio"], [role="menuitem"], [role="switch"], summary';
|
|
13
|
+
const CANDIDATE = `[data-tip], ${INTERACTIVE.split(', ').map((s) => `${s}[aria-label], ${s}[title]`).join(', ')}, [role="img"][aria-label], [role="img"][title]`;
|
|
14
|
+
|
|
15
|
+
// True when the element shows no word of its own: only icons or a symbol.
|
|
16
|
+
export function iconOnly(text) {
|
|
17
|
+
return !/[\p{L}\p{N}]/u.test(String(text ?? ''));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The tip for this element, or null. `el` needs getAttribute, textContent
|
|
21
|
+
// and closest (a DOM element, or a test's stand-in).
|
|
22
|
+
export function tipText(el) {
|
|
23
|
+
if (!el || el.closest?.('[class*="cl-"]')) return null;
|
|
24
|
+
if (!iconOnly(el.textContent) && !el.getAttribute('data-tip')) return null;
|
|
25
|
+
return el.getAttribute('title') || el.getAttribute('data-tip') || el.getAttribute('aria-label') || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Marks a control icon-only: its spoken name and its tip. Returns the node.
|
|
29
|
+
export function named(node, label, tip = label) {
|
|
30
|
+
node.setAttribute('aria-label', label);
|
|
31
|
+
node.setAttribute('data-tip', tip);
|
|
32
|
+
node.removeAttribute('title');
|
|
33
|
+
return node;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let bubble = null;
|
|
37
|
+
let anchor = null;
|
|
38
|
+
|
|
39
|
+
function place() {
|
|
40
|
+
if (!bubble || !anchor) return;
|
|
41
|
+
const box = anchor.getBoundingClientRect();
|
|
42
|
+
const tip = bubble.getBoundingClientRect();
|
|
43
|
+
const gap = 6;
|
|
44
|
+
// A folded sidebar's icons take their tip at the right, as in ClickUp.
|
|
45
|
+
if (anchor.dataset.tipSide === 'right') {
|
|
46
|
+
bubble.style.transform = `translate(${Math.round(box.right + gap)}px, ${Math.round(box.top + box.height / 2 - tip.height / 2)}px)`;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const above = box.top - tip.height - gap;
|
|
50
|
+
const top = above >= 4 ? above : box.bottom + gap;
|
|
51
|
+
const left = Math.min(Math.max(4, box.left + box.width / 2 - tip.width / 2), window.innerWidth - tip.width - 4);
|
|
52
|
+
bubble.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function show(el) {
|
|
56
|
+
const text = tipText(el);
|
|
57
|
+
if (!text) return;
|
|
58
|
+
// A native title would show a second tip after a pause, so the tip takes
|
|
59
|
+
// it over (a title set again later wins).
|
|
60
|
+
if (el.hasAttribute('title')) {
|
|
61
|
+
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', el.getAttribute('title'));
|
|
62
|
+
el.setAttribute('data-tip', el.getAttribute('title'));
|
|
63
|
+
el.removeAttribute('title');
|
|
64
|
+
return show(el);
|
|
65
|
+
}
|
|
66
|
+
if (!bubble) {
|
|
67
|
+
bubble = document.createElement('div');
|
|
68
|
+
bubble.className = 'fx-tip';
|
|
69
|
+
bubble.setAttribute('role', 'tooltip');
|
|
70
|
+
bubble.setAttribute('aria-hidden', 'true');
|
|
71
|
+
}
|
|
72
|
+
bubble.textContent = text;
|
|
73
|
+
anchor = el;
|
|
74
|
+
(el.closest('dialog[open]') ?? document.body).append(bubble);
|
|
75
|
+
place();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hide(el) {
|
|
79
|
+
if (el && el !== anchor) return;
|
|
80
|
+
anchor = null;
|
|
81
|
+
bubble?.remove();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function startTips() {
|
|
85
|
+
document.addEventListener('pointerover', (event) => {
|
|
86
|
+
if (event.pointerType === 'touch') return;
|
|
87
|
+
const el = event.target.closest?.(CANDIDATE);
|
|
88
|
+
if (el && el !== anchor) show(el);
|
|
89
|
+
else if (!el) hide();
|
|
90
|
+
});
|
|
91
|
+
document.addEventListener('focusin', (event) => {
|
|
92
|
+
const el = event.target.closest?.(CANDIDATE);
|
|
93
|
+
if (el && el.matches(':focus-visible')) show(el);
|
|
94
|
+
});
|
|
95
|
+
document.addEventListener('focusout', (event) => hide(event.target.closest?.(CANDIDATE)));
|
|
96
|
+
document.addEventListener('pointerdown', () => hide());
|
|
97
|
+
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') hide(); });
|
|
98
|
+
window.addEventListener('scroll', () => hide(), true);
|
|
99
|
+
}
|
package/server/ui/xero-import.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
49
49
|
const history = params.get('show') === 'all';
|
|
50
50
|
const icon = (name, label) => lucide(h, name, label);
|
|
51
51
|
const host = h('div', { class: 'fx-xi' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
52
|
-
const
|
|
52
|
+
const tally = h('span', {});
|
|
53
53
|
const actions = [];
|
|
54
54
|
let view = params.get('view') === 'list' ? 'list' : 'card';
|
|
55
55
|
if (!history) {
|
|
@@ -66,11 +66,8 @@ export async function renderXeroImport(main, ui) {
|
|
|
66
66
|
renderReview();
|
|
67
67
|
}
|
|
68
68
|
main.append(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
h('a', { href: '/integrations/xero', onclick: (event) => ui.go(event) }, 'Xero'), h('span', { 'aria-hidden': 'true' }, '›'),
|
|
72
|
-
h('span', {}, history ? 'History' : 'Review')),
|
|
73
|
-
ui.pageHeader(history ? 'Xero history' : 'Review', { subtitle, actions }),
|
|
69
|
+
ui.crumbs(['/integrations', 'Integrations'], ['/integrations/xero', 'Xero'], history ? 'History' : 'Review'),
|
|
70
|
+
ui.pageHeader(history ? 'Xero history' : 'Review', { count: tally, actions }),
|
|
74
71
|
host);
|
|
75
72
|
|
|
76
73
|
let organisationId;
|
|
@@ -117,7 +114,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
117
114
|
return;
|
|
118
115
|
}
|
|
119
116
|
if (!rows.length) {
|
|
120
|
-
|
|
117
|
+
tally.replaceChildren('');
|
|
121
118
|
host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Nothing to check.'), go('/xero-import?show=all', 'Xero history')));
|
|
122
119
|
return;
|
|
123
120
|
}
|
|
@@ -134,7 +131,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
134
131
|
const fit = flowFit(row, pick);
|
|
135
132
|
const bar = h('span', { class: 'fx-xi__progress', role: 'progressbar', 'aria-valuemin': '1', 'aria-valuemax': String(rows.length), 'aria-valuenow': String(at + 1) },
|
|
136
133
|
h('span', { style: { width: `${((at + 1) / rows.length) * 100}%` } }));
|
|
137
|
-
|
|
134
|
+
tally.replaceChildren(h('span', { class: 'fx-xi__count' }, bar, `${at + 1} / ${rows.length}`));
|
|
138
135
|
const days = daysApart(row.issuedOn, fit.date);
|
|
139
136
|
const facts = [
|
|
140
137
|
['hash', 'Number', h('span', { class: 'fx-num' }, row.number ?? '—'), h('span', { class: 'fx-num' }, fit.name), null],
|
|
@@ -195,7 +192,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
195
192
|
|
|
196
193
|
// Every one to check, best fit beside it; ticked rows go from the bulk bar.
|
|
197
194
|
function reviewList(rows) {
|
|
198
|
-
|
|
195
|
+
tally.replaceChildren(`${rows.length} to check`);
|
|
199
196
|
const ticked = new Set();
|
|
200
197
|
const boxes = new Map();
|
|
201
198
|
const drawBar = () => {
|
|
@@ -315,7 +312,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
315
312
|
return;
|
|
316
313
|
}
|
|
317
314
|
const total = Object.values(page.counts).reduce((a, b) => a + b, 0);
|
|
318
|
-
|
|
315
|
+
tally.replaceChildren(h('span', { class: 'fx-xi__count' }, icon('check', 'In Flow'), `${page.counts.linked} / ${total}`));
|
|
319
316
|
shown = offset === 0 ? page.rows : [...shown, ...page.rows];
|
|
320
317
|
const rows = page.rows.map(rowOf);
|
|
321
318
|
if (offset === 0) body.replaceChildren(...(rows.length ? rows : [h('tr', {}, h('td', { colspan: '8', class: 'fx-empty' }, q.trim() ? 'No match.' : 'No Xero invoices copied yet.'))]));
|