@arcforgelabs/flow 2026.10.42 → 2026.10.44
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/npm-shrinkwrap.json +4 -3
- package/package.json +5 -3
- package/server/auth/people.js +40 -2
- package/server/auth/routes.js +11 -0
- package/server/auth/session.js +22 -3
- package/server/core/custom-fields.js +4 -3
- package/server/core/invoice-rules.js +8 -3
- package/server/core/job-match.js +6 -2
- package/server/core/sending.js +4 -3
- package/server/integrations/sentry/index.js +9 -1
- package/server/release-channel.js +5 -2
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +46 -162
- package/server/ui/automations.js +9 -3
- package/server/ui/columns.js +268 -0
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +268 -55
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +92 -35
- package/server/ui/invoicing.js +206 -119
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/job-view.js +255 -60
- package/server/ui/jobs.js +197 -145
- package/server/ui/kit.js +278 -0
- package/server/ui/list-views.js +73 -0
- package/server/ui/monitoring.js +1 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- package/server/ui/reconcile.js +114 -61
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +141 -0
- package/server/ui/search.js +8 -0
- package/server/ui/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/view-bar.js +176 -70
- package/server/ui/xero-import.js +7 -10
package/server/ui/kit.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// The shared pieces every Flow page builds with: elements (h), the page
|
|
2
|
+
// header, notes, toasts, dialogs, setting rows, the ⋯ row menu, the button
|
|
3
|
+
// that waits between presses, cards that open a page, and the crumbs trail. app.js hands them to each view module in ui(),
|
|
4
|
+
// and the design system's cards (scripts/design-system/) render these same
|
|
5
|
+
// functions, so a card is the real thing. Reuse one or give it an option;
|
|
6
|
+
// never copy it into a page (AGENTS.md, "UI: reuse first").
|
|
7
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
8
|
+
import { named } from '/assets/ui/tip.js';
|
|
9
|
+
|
|
10
|
+
// h('p', { class: 'x', onclick }, 'text', child) — children are text or nodes.
|
|
11
|
+
export function h(tag, attrs = {}, ...children) {
|
|
12
|
+
const node = document.createElement(tag);
|
|
13
|
+
for (const [key, value] of Object.entries(attrs ?? {})) {
|
|
14
|
+
if (value == null || value === false) continue;
|
|
15
|
+
if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
|
16
|
+
else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
|
|
17
|
+
else node.setAttribute(key, value === true ? '' : String(value));
|
|
18
|
+
}
|
|
19
|
+
for (const child of children.flat()) {
|
|
20
|
+
if (child == null || child === false) continue;
|
|
21
|
+
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
|
22
|
+
}
|
|
23
|
+
return node;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Keeps a button off for `ms`, with the time left on it, then puts it back.
|
|
27
|
+
export function holdButton(button, ms) {
|
|
28
|
+
if (!button || !(ms > 0)) return;
|
|
29
|
+
clearInterval(button.fxHold);
|
|
30
|
+
const label = button.dataset.label ?? button.textContent;
|
|
31
|
+
button.dataset.label = label;
|
|
32
|
+
const until = Date.now() + ms;
|
|
33
|
+
const draw = () => {
|
|
34
|
+
const left = Math.ceil((until - Date.now()) / 1000);
|
|
35
|
+
if (left <= 0) {
|
|
36
|
+
clearInterval(button.fxHold);
|
|
37
|
+
button.textContent = label;
|
|
38
|
+
delete button.dataset.label;
|
|
39
|
+
button.removeAttribute('title');
|
|
40
|
+
button.disabled = false;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const time = left < 60 ? `${left}s` : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
|
44
|
+
button.disabled = true;
|
|
45
|
+
button.textContent = `${label} · ${time}`;
|
|
46
|
+
button.title = `Flow waits before it asks again, to protect the connected system's limits. Ready in ${time}.`;
|
|
47
|
+
};
|
|
48
|
+
draw();
|
|
49
|
+
button.fxHold = setInterval(draw, 1000);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The page's name and nothing under it: the page shows what it is for.
|
|
53
|
+
// `count` is a live number beside the name (2 to check); `help` is a docs
|
|
54
|
+
// link, an icon with its name as a tip.
|
|
55
|
+
export function pageHeader(title, { count = null, help = null, actions = [] } = {}) {
|
|
56
|
+
return h('header', { class: 'fx-ph' },
|
|
57
|
+
h('div', { class: 'fx-ph__text' },
|
|
58
|
+
h('h1', { class: 'fx-title' }, title),
|
|
59
|
+
count != null ? h('span', { class: 'fx-ph__count' }, count) : null,
|
|
60
|
+
help ? helpLink(help) : null),
|
|
61
|
+
actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The top of a list page (Jobs, Invoices, Reconcile), laid out the same on
|
|
65
|
+
// every list and in Board and List alike, so nothing moves when a person
|
|
66
|
+
// switches: the title row, a row for saved view tabs (`views`: the page's
|
|
67
|
+
// savedViews element, else an empty slot that stays hidden), then one toolbar with `start` at the left (tabs, Group,
|
|
68
|
+
// Sort, Filter) and `end` at the right (search, Board/List), then the page's
|
|
69
|
+
// `actions` (New invoice) last, as Claude's own lists do. `line` draws the
|
|
70
|
+
// rule that tabs sit on; `sticky` keeps the toolbar at the top while the rows
|
|
71
|
+
// scroll up under it, with a shadow once they do. Append `nodes` to the page.
|
|
72
|
+
export function listHead(title, { count = null, help = null, actions = [], start = [], end = [], line = false, sticky = false, views: tabs = null } = {}) {
|
|
73
|
+
const views = tabs ?? h('div', { class: 'fx-views', role: 'group', 'aria-label': 'Views', hidden: true });
|
|
74
|
+
const bar = h('div', { class: 'fx-listhead', 'data-sticky': sticky },
|
|
75
|
+
h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, start, h('div', { class: 'fx-toolbar__end' }, end, actions)));
|
|
76
|
+
const edge = sticky ? h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' }) : null;
|
|
77
|
+
if (edge && typeof IntersectionObserver === 'function') {
|
|
78
|
+
const top = window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1;
|
|
79
|
+
new IntersectionObserver(([entry]) => bar.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${top}px 0px 0px 0px` }).observe(edge);
|
|
80
|
+
}
|
|
81
|
+
return { nodes: [pageHeader(title, { count, help }), views, edge, bar].filter(Boolean), views, bar };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The window a list or board sits in under listHead, as HubSpot's: on a
|
|
85
|
+
// desktop it fills the page to the bottom and scrolls inside itself both
|
|
86
|
+
// ways, so nothing runs off the page, with a foot bar for the count and its
|
|
87
|
+
// tools. It makes `main` the window's height (fx-main--frame). Rows fade
|
|
88
|
+
// under an edge they scroll past (edgeFade); the sticky table head is the
|
|
89
|
+
// top edge's own break.
|
|
90
|
+
export function listFrame(main, content) {
|
|
91
|
+
main.classList.add('fx-main--frame');
|
|
92
|
+
const count = h('span', { class: 'fx-frame__count', role: 'status' });
|
|
93
|
+
const tools = h('div', { class: 'fx-frame__tools' });
|
|
94
|
+
const body = h('div', { class: 'fx-frame__body' }, content);
|
|
95
|
+
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));
|
|
97
|
+
edgeFade(body, { target: view, edges: ['bottom', 'left', 'right'] });
|
|
98
|
+
return { element, body, view, tools, count: (text) => { count.textContent = text ?? ''; } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A soft fade at each edge of `target` that `scroller` has more content
|
|
102
|
+
// past, so rows and cards fade under it instead of vanishing. No fade on
|
|
103
|
+
// an edge a scrollbar already marks (the bottom under a sideways bar, the
|
|
104
|
+
// right beside a down bar). `target` holds the fades and must not scroll.
|
|
105
|
+
const fading = typeof ResizeObserver === 'function'
|
|
106
|
+
? new ResizeObserver((entries) => entries.forEach(({ target }) => (target.isConnected ? target.fxFade?.() : fading.unobserve(target))))
|
|
107
|
+
: null;
|
|
108
|
+
export function edgeFade(scroller, { target = scroller.parentElement, edges = ['top', 'bottom', 'left', 'right'] } = {}) {
|
|
109
|
+
target.classList.add('fx-fades');
|
|
110
|
+
for (const edge of edges) {
|
|
111
|
+
if (!target.querySelector(`:scope > .fx-fade[data-edge="${edge}"]`)) target.append(h('span', { class: 'fx-fade', 'data-edge': edge, 'aria-hidden': 'true' }));
|
|
112
|
+
}
|
|
113
|
+
const update = () => {
|
|
114
|
+
const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth, offsetHeight, offsetWidth } = scroller;
|
|
115
|
+
const more = {
|
|
116
|
+
top: scrollTop > 1,
|
|
117
|
+
bottom: scrollTop + clientHeight < scrollHeight - 1 && offsetHeight - clientHeight === 0,
|
|
118
|
+
left: scrollLeft > 1,
|
|
119
|
+
right: scrollLeft + clientWidth < scrollWidth - 1 && offsetWidth - clientWidth === 0,
|
|
120
|
+
};
|
|
121
|
+
// Several scrollers can share a target (the board in the frame): it
|
|
122
|
+
// fades each edge any of them has more past.
|
|
123
|
+
target.fxFades ??= new Map();
|
|
124
|
+
target.fxFades.set(scroller, edges.filter((edge) => more[edge]));
|
|
125
|
+
for (const other of target.fxFades.keys()) if (!other.isConnected) target.fxFades.delete(other);
|
|
126
|
+
target.dataset.fade = [...new Set([...target.fxFades.values()].flat())].join(' ');
|
|
127
|
+
};
|
|
128
|
+
scroller.fxFade = update;
|
|
129
|
+
scroller.addEventListener('scroll', update, { passive: true });
|
|
130
|
+
fading?.observe(scroller);
|
|
131
|
+
requestAnimationFrame(update);
|
|
132
|
+
return update;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// A docs link as a small "?" icon whose tip names it (How sending works).
|
|
136
|
+
export function helpLink({ href, label }) {
|
|
137
|
+
return named(h('a', { class: 'fx-help', href, target: '_blank', rel: 'noopener' }, lucide(h, 'circle-help')), label);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A settings section's heading, with its docs link beside it.
|
|
141
|
+
export function sectionTitle(text, help = null) {
|
|
142
|
+
return h('h2', { class: 'fx-set__title' }, text, help ? helpLink(help) : null);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function notice(text, tone = 'info', title = null) {
|
|
146
|
+
return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
|
|
147
|
+
title ? h('strong', {}, title) : null,
|
|
148
|
+
h('span', {}, text));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A field that fails a check glows red, like a required field, until the
|
|
152
|
+
// person types in it; the next check marks it again if it still fails.
|
|
153
|
+
// `on` false clears it. Answers the field.
|
|
154
|
+
export function fieldError(field, on = true) {
|
|
155
|
+
if (!on) { field.removeAttribute('aria-invalid'); return field; }
|
|
156
|
+
field.setAttribute('aria-invalid', 'true');
|
|
157
|
+
field.addEventListener('input', () => field.removeAttribute('aria-invalid'), { once: true });
|
|
158
|
+
return field;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function toast(text, tone = 'info') {
|
|
162
|
+
const region = document.getElementById('toasts');
|
|
163
|
+
const item = h('div', { class: 'fx-toast', 'data-tone': tone, role: 'status' }, text);
|
|
164
|
+
region.append(item);
|
|
165
|
+
setTimeout(() => item.remove(), 4200);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// A modal built on <dialog>: resolves with the button's value, or null.
|
|
169
|
+
export function dialog({ title, body, confirm, tone = 'primary' }) {
|
|
170
|
+
return new Promise((resolve) => {
|
|
171
|
+
const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
|
|
172
|
+
h('h2', { class: 'fx-dialog__title' }, title),
|
|
173
|
+
body,
|
|
174
|
+
h('div', { class: 'fx-dialog__actions' },
|
|
175
|
+
h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
|
|
176
|
+
h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
|
|
177
|
+
const element = h('dialog', { class: 'fx-dialog' }, form);
|
|
178
|
+
element.addEventListener('close', () => {
|
|
179
|
+
const ok = element.returnValue === 'ok';
|
|
180
|
+
const data = ok ? new FormData(form) : null;
|
|
181
|
+
element.remove();
|
|
182
|
+
resolve(ok ? data : null);
|
|
183
|
+
});
|
|
184
|
+
document.body.append(element);
|
|
185
|
+
element.showModal();
|
|
186
|
+
element.querySelector('input, select, button[value="ok"]')?.focus();
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// One setting, laid out the same on every settings screen: a short name, one
|
|
191
|
+
// line of smaller text under it, and the control on the right. Longer help
|
|
192
|
+
// lives behind a link, not here. A wide control (a grid of choices) sits on
|
|
193
|
+
// its own line under the name. `tone: 'danger'` marks a row that needs a person.
|
|
194
|
+
export function settingRow({ label, hint, control, wide = false, tone = null }) {
|
|
195
|
+
const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
|
|
196
|
+
const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
|
|
197
|
+
return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
|
|
198
|
+
h('div', { class: 'fx-setting__text' },
|
|
199
|
+
id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
|
|
200
|
+
hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
|
|
201
|
+
h('div', { class: 'fx-setting__control' }, control));
|
|
202
|
+
}
|
|
203
|
+
settingRow.next = 0;
|
|
204
|
+
|
|
205
|
+
// The steps of a Connect, each ticked, running or crossed, with the time taken.
|
|
206
|
+
|
|
207
|
+
let rowMenus = 0;
|
|
208
|
+
|
|
209
|
+
// A row's ⋯ menu, as on the Custom fields page. The menu lines up with the
|
|
210
|
+
// button's right edge; `align: 'start'` lines it up with its left edge.
|
|
211
|
+
export function rowMenu(label, items, { align = 'end' } = {}) {
|
|
212
|
+
const id = `fx-row-menu-${++rowMenus}`;
|
|
213
|
+
const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, lucide(h, 'ellipsis'));
|
|
214
|
+
const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
|
|
215
|
+
panel.addEventListener('toggle', (event) => {
|
|
216
|
+
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
217
|
+
if (event.newState !== 'open') return;
|
|
218
|
+
const close = () => panel.hidePopover();
|
|
219
|
+
panel.replaceChildren(...items.map((entry) => (entry === '-'
|
|
220
|
+
? h('div', { class: 'fx-pop__sep' })
|
|
221
|
+
: h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
|
|
222
|
+
h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
|
|
223
|
+
const at = button.getBoundingClientRect();
|
|
224
|
+
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
225
|
+
const left = align === 'start' ? at.left : at.right - panel.offsetWidth;
|
|
226
|
+
panel.style.left = `${Math.round(Math.max(8, Math.min(left, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
227
|
+
});
|
|
228
|
+
return [button, panel];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Where a link in a shared piece opens: app.js passes its own `go`, so a click
|
|
232
|
+
// opens the page in place (a new tab still works). Without it, as in the
|
|
233
|
+
// design system, a link is a plain link.
|
|
234
|
+
let openPath = null;
|
|
235
|
+
export function linksOpenWith(go) { openPath = go; }
|
|
236
|
+
function inPlace(event) {
|
|
237
|
+
if (!openPath || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
openPath(event.currentTarget.getAttribute('href'));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A card that opens a page. Large (Integrations): its mark (a logo node, or a
|
|
243
|
+
// Lucide icon name), the name, a status pill, two lines at most (only when
|
|
244
|
+
// they help), and the action. `row` is the one-line card of a tileSection,
|
|
245
|
+
// like Claude's Customize categories: the mark at the left, the name, and a
|
|
246
|
+
// short dim `meta` value or the status pill at the right.
|
|
247
|
+
export function tileCard({ href, mark, title, status, blurb, meta, action = 'Manage', row = false }) {
|
|
248
|
+
const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, lucide(h, mark)) : mark;
|
|
249
|
+
const pill = status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null;
|
|
250
|
+
if (row) {
|
|
251
|
+
return h('a', { class: 'fx-tile', href, onclick: inPlace },
|
|
252
|
+
logo,
|
|
253
|
+
h('h3', {}, title),
|
|
254
|
+
pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null));
|
|
255
|
+
}
|
|
256
|
+
return h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
|
|
257
|
+
h('header', { class: 'fx-int-card__head' }, logo, h('h3', {}, title), pill),
|
|
258
|
+
blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
|
|
259
|
+
h('span', { class: 'fx-int-card__btn' }, action));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// A titled group of one-line cards, three columns where they fit, with "Show all" at the right
|
|
263
|
+
// when `href` is given (Claude's Customize page).
|
|
264
|
+
export function tileSection(title, cards, { href, more = 'Show all' } = {}) {
|
|
265
|
+
return h('section', { class: 'fx-tiles' },
|
|
266
|
+
h('header', { class: 'fx-tiles__head' },
|
|
267
|
+
h('h2', {}, title),
|
|
268
|
+
href ? h('a', { class: 'fx-tiles__more', href, onclick: inPlace }, more, lucide(h, 'chevron-right')) : null),
|
|
269
|
+
h('div', { class: 'fx-tiles__grid' }, cards));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
|
|
273
|
+
export function crumbs(...trail) {
|
|
274
|
+
const here = trail.pop();
|
|
275
|
+
return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
|
|
276
|
+
...trail.flatMap(([href, label]) => [h('a', { href, onclick: inPlace }, label), h('span', { 'aria-hidden': 'true' }, '›')]),
|
|
277
|
+
h('span', {}, here));
|
|
278
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// A person's saved views of a list page (Jobs, Invoices, Reconcile), as
|
|
2
|
+
// ClickUp's view tabs: each has a name and the page's view (Group, Sort,
|
|
3
|
+
// Filter, Columns), and one can be the person's default, which opens when
|
|
4
|
+
// they go to the page. No DOM: the server checks a saved list with the same
|
|
5
|
+
// rules the page uses (server/auth/people.js).
|
|
6
|
+
import { cleanFilterView, normaliseJobsView } from './job-view.js';
|
|
7
|
+
|
|
8
|
+
// The Invoices filter's fields (invoicing.js). The tabs are the status, so
|
|
9
|
+
// Status is not here.
|
|
10
|
+
export const INVOICE_FILTER_FIELDS = {
|
|
11
|
+
client: { label: 'Client', kind: 'choice', icon: 'building-2' },
|
|
12
|
+
number: { label: 'Number', kind: 'text', icon: 'hash' },
|
|
13
|
+
reference: { label: 'Reference', kind: 'text', icon: 'text' },
|
|
14
|
+
date: { label: 'Date', kind: 'date', icon: 'calendar' },
|
|
15
|
+
total: { label: 'Total', kind: 'number', icon: 'dollar-sign' },
|
|
16
|
+
problem: { label: 'Problem', kind: 'check', icon: 'triangle-alert' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// The Reconcile filter's fields (reconcile.js). Reconcile lists jobs and
|
|
20
|
+
// invoices, so it has both lists' fields; a job's own field (Practitioner,
|
|
21
|
+
// a custom field) skips invoice rows, so it never hides them.
|
|
22
|
+
export const RECONCILE_FILTER_FIELDS = {
|
|
23
|
+
type: { label: 'Type', kind: 'choice', icon: 'layers' },
|
|
24
|
+
status: { label: 'Status', kind: 'choice', icon: 'circle-dot' },
|
|
25
|
+
client: { label: 'Client', kind: 'choice', icon: 'building-2' },
|
|
26
|
+
practitioner: { label: 'Practitioner', kind: 'choice', icon: 'stethoscope', job: true },
|
|
27
|
+
date: { label: 'Date', kind: 'date', icon: 'calendar' },
|
|
28
|
+
total: { label: 'Total', kind: 'number', icon: 'dollar-sign' },
|
|
29
|
+
match: { label: 'Match', kind: 'check', icon: 'link' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const CUSTOM_FIELD = /^field:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
33
|
+
const kindIn = (fields, custom) => (field) => (Object.hasOwn(fields, field) ? fields[field].kind : custom && CUSTOM_FIELD.test(field) ? '*' : null);
|
|
34
|
+
|
|
35
|
+
// The Invoices and Reconcile views: their filter.
|
|
36
|
+
export const normaliseInvoicesView = (value) => cleanFilterView(value, kindIn(INVOICE_FILTER_FIELDS, false));
|
|
37
|
+
export const normaliseReconcileView = (value) => cleanFilterView(value, kindIn(RECONCILE_FILTER_FIELDS, true));
|
|
38
|
+
|
|
39
|
+
// The pages with saved views. Each is named in a switch, never looked up
|
|
40
|
+
// by the name a request sends, so a name such as "constructor" is refused.
|
|
41
|
+
export const SAVED_VIEW_PAGES = Object.freeze(['jobs', 'invoices', 'reconcile']);
|
|
42
|
+
export function pageViewCheck(page) {
|
|
43
|
+
switch (page) {
|
|
44
|
+
case 'jobs': return normaliseJobsView;
|
|
45
|
+
case 'invoices': return normaliseInvoicesView;
|
|
46
|
+
case 'reconcile': return normaliseReconcileView;
|
|
47
|
+
default: return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const MAX_SAVED_VIEWS = 20;
|
|
52
|
+
const MAX_NAME = 60;
|
|
53
|
+
const VIEW_ID = /^[a-z0-9-]{1,40}$/i;
|
|
54
|
+
|
|
55
|
+
// A page's saved views as Flow keeps them: { list: [{ id, name, view }],
|
|
56
|
+
// default }. Bad entries are dropped; a default that names no view is none.
|
|
57
|
+
export function normaliseSavedViews(page, input) {
|
|
58
|
+
const check = pageViewCheck(page);
|
|
59
|
+
const value = input && typeof input === 'object' ? input : {};
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const list = check && Array.isArray(value.list) ? value.list : [];
|
|
62
|
+
const kept = list.filter((v) => v && typeof v === 'object' && typeof v.id === 'string' && VIEW_ID.test(v.id) && !seen.has(v.id) && seen.add(v.id))
|
|
63
|
+
.map((v) => ({ id: v.id, name: String(v.name ?? '').trim().slice(0, MAX_NAME), view: check(v.view) }))
|
|
64
|
+
.filter((v) => v.name.length > 0)
|
|
65
|
+
.slice(0, MAX_SAVED_VIEWS);
|
|
66
|
+
return { list: kept, default: kept.some((v) => v.id === value.default) ? value.default : null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Do two views of a page show the same thing?
|
|
70
|
+
export function sameView(page, a, b) {
|
|
71
|
+
const check = pageViewCheck(page);
|
|
72
|
+
return Boolean(check) && JSON.stringify(check(a)) === JSON.stringify(check(b));
|
|
73
|
+
}
|
package/server/ui/monitoring.js
CHANGED
|
@@ -99,6 +99,7 @@ export async function startMonitoring({ flow, person }) {
|
|
|
99
99
|
integrations,
|
|
100
100
|
tracesSampleRate,
|
|
101
101
|
beforeSendSpan: scrubSpan,
|
|
102
|
+
initialScope: { tags: config.tags ?? {} },
|
|
102
103
|
});
|
|
103
104
|
Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
|
|
104
105
|
// Who reported or met the error, so the lab can follow up.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// How Flow names a person, in one place: a person field's choices, its
|
|
2
|
+
// filter values and lanes, the Assignee badge, and the People page. The
|
|
3
|
+
// label is the person's full name; with no name, their whole email. No DOM,
|
|
4
|
+
// so the server builds the same label (server/core/custom-fields.js
|
|
5
|
+
// choosablePeople) and tests can read it (test/person.test.js).
|
|
6
|
+
|
|
7
|
+
// The longest label a native menu shows. A longer one ends in "…" and the
|
|
8
|
+
// full label is the option's hover name. Pages with their own lists clip
|
|
9
|
+
// with CSS (`text-overflow: ellipsis`) instead.
|
|
10
|
+
export const LABEL_MAX = 32;
|
|
11
|
+
|
|
12
|
+
// { name, email } → "Jane Citizen", or "jane@example.test" with no name.
|
|
13
|
+
export function personLabel(person) {
|
|
14
|
+
return String(person?.name ?? '').trim() || String(person?.email ?? '').trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A badge's letters: the first letters of the first two words of the label
|
|
18
|
+
// ("JC"), or of the email's part before @ ("jane.citizen@…" → "JC").
|
|
19
|
+
export function initialsOf(person) {
|
|
20
|
+
const source = (typeof person === 'string' ? person : personLabel(person)).replace(/@.*/, '');
|
|
21
|
+
const parts = source.split(/[\s._-]+/).filter(Boolean);
|
|
22
|
+
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A label cut to `max` characters with "…" at the end, on one line.
|
|
26
|
+
export function clipLabel(text, max = LABEL_MAX) {
|
|
27
|
+
const value = String(text ?? '');
|
|
28
|
+
return value.length > max ? `${value.slice(0, max - 1).trimEnd()}…` : value;
|
|
29
|
+
}
|
package/server/ui/price-list.js
CHANGED
|
@@ -29,7 +29,7 @@ export async function renderPriceList(main, ui) {
|
|
|
29
29
|
const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Add code');
|
|
30
30
|
const reconcileButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', hidden: true, onclick: () => ui.go('/price-list/reconcile') }, 'Reconcile');
|
|
31
31
|
const alerts = alertStrip(ui, 'price-list');
|
|
32
|
-
main.append(pageHeader('Price list', {
|
|
32
|
+
main.append(pageHeader('Price list', { help: { href: DOCS, label: 'How the price list works' }, actions: [reconcileButton, addButton] }), alerts.element, host);
|
|
33
33
|
|
|
34
34
|
let data = null;
|
|
35
35
|
let query = '';
|
|
@@ -176,8 +176,7 @@ export async function renderPriceList(main, ui) {
|
|
|
176
176
|
table,
|
|
177
177
|
];
|
|
178
178
|
const settings = h('section', { class: 'fx-set fx-price__settings' }, h('h2', { class: 'fx-set__title' }, 'Invoice lines'), h('div', { class: 'fx-skeleton' }));
|
|
179
|
-
linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r
|
|
180
|
-
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How the price list works'))));
|
|
179
|
+
linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r));
|
|
181
180
|
sections.push(settings);
|
|
182
181
|
host.replaceChildren(...sections.filter(Boolean));
|
|
183
182
|
// Opened from Reconcile's "Edit code": that code's dialog, once.
|
|
@@ -26,17 +26,16 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
26
26
|
const icon = (name, label) => lucide(h, name, label);
|
|
27
27
|
let view = new URLSearchParams(location.search).get('view') === 'list' ? 'list' : 'card';
|
|
28
28
|
const host = h('div', { class: 'fx-xi fx-prc' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
29
|
-
const
|
|
29
|
+
const tally = h('span', {});
|
|
30
30
|
const toggle = (name, label, glyph) => h('button', {
|
|
31
31
|
class: 'fx-xi__view', type: 'button', role: 'tab', 'aria-selected': String(view === name), 'aria-label': label, title: label, 'data-view': name,
|
|
32
32
|
onclick: () => setView(name),
|
|
33
33
|
}, icon(glyph));
|
|
34
34
|
const bulk = bulkBar(ui, { label: 'Ticked records' });
|
|
35
35
|
main.append(
|
|
36
|
-
|
|
37
|
-
h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, 'Reconcile')),
|
|
36
|
+
ui.crumbs(['/price-list', 'Price list'], 'Reconcile'),
|
|
38
37
|
ui.pageHeader('Reconcile prices', {
|
|
39
|
-
|
|
38
|
+
count: tally,
|
|
40
39
|
actions: [h('div', { class: 'fx-xi__views', role: 'tablist', 'aria-label': 'View' }, toggle('card', 'One at a time', 'square'), toggle('list', 'List, tick many', 'list-checks'))],
|
|
41
40
|
}),
|
|
42
41
|
host, bulk.element);
|
|
@@ -129,13 +128,13 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
129
128
|
if (!data) return;
|
|
130
129
|
bulk.draw({ count: 0 });
|
|
131
130
|
if (!data.systems.length) {
|
|
132
|
-
|
|
131
|
+
tally.replaceChildren('');
|
|
133
132
|
host.replaceChildren(h('p', { class: 'fx-empty' }, 'No integration with a price list is on. Flow uses its own list alone.'),
|
|
134
133
|
h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, '← Price list'));
|
|
135
134
|
return;
|
|
136
135
|
}
|
|
137
136
|
if (!rows.length) {
|
|
138
|
-
|
|
137
|
+
tally.replaceChildren('');
|
|
139
138
|
host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Every record has a code, and every price agrees.'),
|
|
140
139
|
h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list')));
|
|
141
140
|
return;
|
|
@@ -184,7 +183,7 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
184
183
|
// One record (or one differing code) at a time.
|
|
185
184
|
function drawCard() {
|
|
186
185
|
const row = rows[at];
|
|
187
|
-
|
|
186
|
+
tally.replaceChildren(`${rows.length} to check`);
|
|
188
187
|
const next = () => { at = (at + 1) % rows.length; pick = 0; chosen = null; drawCard(); };
|
|
189
188
|
const record = row.record;
|
|
190
189
|
const item = row.kind === 'differs' ? row.item : chosen ?? row.candidates[pick]?.item ?? null;
|
|
@@ -267,7 +266,7 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
267
266
|
|
|
268
267
|
// Every record and code to check; ticked rows go from the bulk bar.
|
|
269
268
|
function drawList() {
|
|
270
|
-
|
|
269
|
+
tally.replaceChildren(`${rows.length} to check`);
|
|
271
270
|
const ticked = new Set();
|
|
272
271
|
const boxes = new Map();
|
|
273
272
|
const picked = () => rows.filter((r) => ticked.has(r.key));
|