@arcforgelabs/flow 2026.10.43 → 2026.10.45
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/api/invoicing.js +53 -3
- package/server/auth/people.js +41 -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/release-channel.js +5 -2
- package/server/ui/app.js +53 -197
- package/server/ui/columns.js +286 -0
- package/server/ui/flow.css +230 -43
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +52 -29
- package/server/ui/invoicing.js +219 -72
- package/server/ui/job-view.js +276 -60
- package/server/ui/jobs.js +133 -137
- package/server/ui/kit.js +279 -0
- package/server/ui/list-views.js +115 -0
- package/server/ui/monitoring.js +51 -8
- package/server/ui/pager.js +67 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +27 -7
- 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/view-bar.js +176 -70
package/server/ui/kit.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
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
|
+
// `trigger` swaps the ⋯ icon for the given contents (the sidebar's More).
|
|
212
|
+
export function rowMenu(label, items, { trigger = null, className = 'fx-cfm__more', align = 'end' } = {}) {
|
|
213
|
+
const id = `fx-row-menu-${++rowMenus}`;
|
|
214
|
+
const button = h('button', { class: className, type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, trigger ?? lucide(h, 'ellipsis'));
|
|
215
|
+
const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
|
|
216
|
+
panel.addEventListener('toggle', (event) => {
|
|
217
|
+
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
218
|
+
if (event.newState !== 'open') return;
|
|
219
|
+
const close = () => panel.hidePopover();
|
|
220
|
+
panel.replaceChildren(...items.map((entry) => (entry === '-'
|
|
221
|
+
? h('div', { class: 'fx-pop__sep' })
|
|
222
|
+
: h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
|
|
223
|
+
h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
|
|
224
|
+
const at = button.getBoundingClientRect();
|
|
225
|
+
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
226
|
+
const left = align === 'start' ? at.left : at.right - panel.offsetWidth;
|
|
227
|
+
panel.style.left = `${Math.round(Math.max(8, Math.min(left, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
228
|
+
});
|
|
229
|
+
return [button, panel];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Where a link in a shared piece opens: app.js passes its own `go`, so a click
|
|
233
|
+
// opens the page in place (a new tab still works). Without it, as in the
|
|
234
|
+
// design system, a link is a plain link.
|
|
235
|
+
let openPath = null;
|
|
236
|
+
export function linksOpenWith(go) { openPath = go; }
|
|
237
|
+
function inPlace(event) {
|
|
238
|
+
if (!openPath || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
openPath(event.currentTarget.getAttribute('href'));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A card that opens a page. Large (Integrations): its mark (a logo node, or a
|
|
244
|
+
// Lucide icon name), the name, a status pill, two lines at most (only when
|
|
245
|
+
// they help), and the action. `row` is the one-line card of a tileSection,
|
|
246
|
+
// like Claude's Customize categories: the mark at the left, the name, and a
|
|
247
|
+
// short dim `meta` value or the status pill at the right.
|
|
248
|
+
export function tileCard({ href, mark, title, status, blurb, meta, action = 'Manage', row = false }) {
|
|
249
|
+
const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, lucide(h, mark)) : mark;
|
|
250
|
+
const pill = status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null;
|
|
251
|
+
if (row) {
|
|
252
|
+
return h('a', { class: 'fx-tile', href, onclick: inPlace },
|
|
253
|
+
logo,
|
|
254
|
+
h('h3', {}, title),
|
|
255
|
+
pill ?? (meta ? h('span', { class: 'fx-tile__meta' }, meta) : null));
|
|
256
|
+
}
|
|
257
|
+
return h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: inPlace },
|
|
258
|
+
h('header', { class: 'fx-int-card__head' }, logo, h('h3', {}, title), pill),
|
|
259
|
+
blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
|
|
260
|
+
h('span', { class: 'fx-int-card__btn' }, action));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// A titled group of one-line cards, three columns where they fit, with "Show all" at the right
|
|
264
|
+
// when `href` is given (Claude's Customize page).
|
|
265
|
+
export function tileSection(title, cards, { href, more = 'Show all' } = {}) {
|
|
266
|
+
return h('section', { class: 'fx-tiles' },
|
|
267
|
+
h('header', { class: 'fx-tiles__head' },
|
|
268
|
+
h('h2', {}, title),
|
|
269
|
+
href ? h('a', { class: 'fx-tiles__more', href, onclick: inPlace }, more, lucide(h, 'chevron-right')) : null),
|
|
270
|
+
h('div', { class: 'fx-tiles__grid' }, cards));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
|
|
274
|
+
export function crumbs(...trail) {
|
|
275
|
+
const here = trail.pop();
|
|
276
|
+
return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
|
|
277
|
+
...trail.flatMap(([href, label]) => [h('a', { href, onclick: inPlace }, label), h('span', { 'aria-hidden': 'true' }, '›')]),
|
|
278
|
+
h('span', {}, here));
|
|
279
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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, filterJobs, 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. The page shows the chip (view-bar.js); the server keeps
|
|
10
|
+
// the rows (GET /api/invoicing/invoices), so a filter reaches every invoice
|
|
11
|
+
// of a tab.
|
|
12
|
+
export const INVOICE_FILTER_FIELDS = {
|
|
13
|
+
client: { label: 'Client', kind: 'choice', icon: 'building-2' },
|
|
14
|
+
number: { label: 'Number', kind: 'text', icon: 'hash' },
|
|
15
|
+
reference: { label: 'Reference', kind: 'text', icon: 'text' },
|
|
16
|
+
date: { label: 'Date', kind: 'date', icon: 'calendar' },
|
|
17
|
+
total: { label: 'Total', kind: 'number', icon: 'dollar-sign' },
|
|
18
|
+
problem: { label: 'Problem', kind: 'check', icon: 'triangle-alert' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const invoiceFilterKind = (field) => INVOICE_FILTER_FIELDS[field]?.kind ?? null;
|
|
22
|
+
|
|
23
|
+
// 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.
|
|
25
|
+
export function invoiceValue(invoice, field) {
|
|
26
|
+
switch (field) {
|
|
27
|
+
case 'client': return invoice.contactName ?? null;
|
|
28
|
+
case 'number': return invoice.number ?? null;
|
|
29
|
+
case 'reference': return invoice.reference ?? null;
|
|
30
|
+
case 'date': return invoice.issueDate ?? (invoice.createdAt ? new Date(invoice.createdAt).toISOString().slice(0, 10) : null);
|
|
31
|
+
case 'total': return invoice.totalCents == null ? null : invoice.totalCents / 100;
|
|
32
|
+
case 'problem': return (invoice.problems?.length ?? 0) > 0;
|
|
33
|
+
default: return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// What the Invoices list sorts by (a column head). No sort is newest first.
|
|
38
|
+
export const INVOICE_SORTS = Object.freeze(['number', 'client', 'reference', 'date', 'total']);
|
|
39
|
+
|
|
40
|
+
// One page of an invoice list: the Filter view keeps rows, the sort orders
|
|
41
|
+
// them (an empty value last, either way), then `offset` and `limit` cut the
|
|
42
|
+
// page. `total` and `totalCents` are of every kept row. The rows come in
|
|
43
|
+
// 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: invoiceFilterKind, today });
|
|
46
|
+
const sign = dir === 'desc' ? -1 : 1;
|
|
47
|
+
const ordered = INVOICE_SORTS.includes(sort)
|
|
48
|
+
? kept.map((invoice, i) => [invoice, invoiceValue(invoice, sort), i]).sort(([, x, i], [, y, j]) => {
|
|
49
|
+
if (x == null || y == null) return x == null && y == null ? i - j : (x == null) - (y == null);
|
|
50
|
+
const order = typeof x === 'number' ? x - y : String(x).localeCompare(String(y), 'en', { numeric: true });
|
|
51
|
+
return order ? order * sign : i - j;
|
|
52
|
+
}).map(([invoice]) => invoice)
|
|
53
|
+
: kept;
|
|
54
|
+
return {
|
|
55
|
+
rows: ordered.slice(offset, offset + limit),
|
|
56
|
+
total: kept.length,
|
|
57
|
+
totalCents: kept.reduce((sum, i) => sum + (i.totalCents ?? 0), 0),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The Reconcile filter's fields (reconcile.js). Reconcile lists jobs and
|
|
62
|
+
// invoices, so it has both lists' fields; a job's own field (Practitioner,
|
|
63
|
+
// a custom field) skips invoice rows, so it never hides them.
|
|
64
|
+
export const RECONCILE_FILTER_FIELDS = {
|
|
65
|
+
type: { label: 'Type', kind: 'choice', icon: 'layers' },
|
|
66
|
+
status: { label: 'Status', kind: 'choice', icon: 'circle-dot' },
|
|
67
|
+
client: { label: 'Client', kind: 'choice', icon: 'building-2' },
|
|
68
|
+
practitioner: { label: 'Practitioner', kind: 'choice', icon: 'stethoscope', job: true },
|
|
69
|
+
date: { label: 'Date', kind: 'date', icon: 'calendar' },
|
|
70
|
+
total: { label: 'Total', kind: 'number', icon: 'dollar-sign' },
|
|
71
|
+
match: { label: 'Match', kind: 'check', icon: 'link' },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
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;
|
|
75
|
+
const kindIn = (fields, custom) => (field) => (Object.hasOwn(fields, field) ? fields[field].kind : custom && CUSTOM_FIELD.test(field) ? '*' : null);
|
|
76
|
+
|
|
77
|
+
// The Invoices and Reconcile views: their filter.
|
|
78
|
+
export const normaliseInvoicesView = (value) => cleanFilterView(value, kindIn(INVOICE_FILTER_FIELDS, false));
|
|
79
|
+
export const normaliseReconcileView = (value) => cleanFilterView(value, kindIn(RECONCILE_FILTER_FIELDS, true));
|
|
80
|
+
|
|
81
|
+
// The pages with saved views. Each is named in a switch, never looked up
|
|
82
|
+
// by the name a request sends, so a name such as "constructor" is refused.
|
|
83
|
+
export const SAVED_VIEW_PAGES = Object.freeze(['jobs', 'invoices', 'reconcile']);
|
|
84
|
+
export function pageViewCheck(page) {
|
|
85
|
+
switch (page) {
|
|
86
|
+
case 'jobs': return normaliseJobsView;
|
|
87
|
+
case 'invoices': return normaliseInvoicesView;
|
|
88
|
+
case 'reconcile': return normaliseReconcileView;
|
|
89
|
+
default: return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const MAX_SAVED_VIEWS = 20;
|
|
94
|
+
const MAX_NAME = 60;
|
|
95
|
+
const VIEW_ID = /^[a-z0-9-]{1,40}$/i;
|
|
96
|
+
|
|
97
|
+
// A page's saved views as Flow keeps them: { list: [{ id, name, view }],
|
|
98
|
+
// default }. Bad entries are dropped; a default that names no view is none.
|
|
99
|
+
export function normaliseSavedViews(page, input) {
|
|
100
|
+
const check = pageViewCheck(page);
|
|
101
|
+
const value = input && typeof input === 'object' ? input : {};
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
const list = check && Array.isArray(value.list) ? value.list : [];
|
|
104
|
+
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))
|
|
105
|
+
.map((v) => ({ id: v.id, name: String(v.name ?? '').trim().slice(0, MAX_NAME), view: check(v.view) }))
|
|
106
|
+
.filter((v) => v.name.length > 0)
|
|
107
|
+
.slice(0, MAX_SAVED_VIEWS);
|
|
108
|
+
return { list: kept, default: kept.some((v) => v.id === value.default) ? value.default : null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Do two views of a page show the same thing?
|
|
112
|
+
export function sameView(page, a, b) {
|
|
113
|
+
const check = pageViewCheck(page);
|
|
114
|
+
return Boolean(check) && JSON.stringify(check(a)) === JSON.stringify(check(b));
|
|
115
|
+
}
|
package/server/ui/monitoring.js
CHANGED
|
@@ -17,15 +17,46 @@ function loadScript({ src, integrity }) {
|
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// "
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
20
|
+
// "Request a response": a tick box under the form's email field, off by
|
|
21
|
+
// default. The email field (Sentry's own words) starts with the signed-in
|
|
22
|
+
// person's email, but an address alone does not ask for a reply. A report
|
|
23
|
+
// sent with the box ticked and an address is tagged notify=yes, so the
|
|
24
|
+
// support desk replies (fixed, planned or won't fix). Every other report
|
|
25
|
+
// sends notify=no. Sentry's form has no tick box of its own (SDK 11.4.0),
|
|
26
|
+
// so Flow adds one to it (addResponseBox).
|
|
27
|
+
export function tagNotify(event, wantsResponse = false) {
|
|
26
28
|
if (event?.type !== 'feedback') return;
|
|
27
29
|
const email = String(event.contexts?.feedback?.contact_email ?? '').trim();
|
|
28
|
-
event.tags = { ...event.tags, notify: email ? 'yes' : 'no' };
|
|
30
|
+
event.tags = { ...event.tags, notify: wantsResponse && email ? 'yes' : 'no' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const RESPONSE_LABEL = 'Request a response';
|
|
34
|
+
|
|
35
|
+
// The tick box goes under the email field inside the form's shadow root, so
|
|
36
|
+
// flow.css cannot reach it: its look comes from the form's own variables,
|
|
37
|
+
// which flow.css sets from the --t-* tokens (#sentry-feedback). Ticked, the
|
|
38
|
+
// email is required. No email field (or no form yet): no box.
|
|
39
|
+
const RESPONSE_STYLE = `
|
|
40
|
+
.form__response { display: flex; flex-direction: row; align-items: center; gap: 8px; margin: 0; cursor: pointer; }
|
|
41
|
+
.form__response input { margin: 0; width: 16px; height: 16px; accent-color: var(--accent-background); cursor: pointer; }`;
|
|
42
|
+
function addResponseBox(root, onChange) {
|
|
43
|
+
const email = root?.querySelector('input[name="email"]:not([type="hidden"])');
|
|
44
|
+
if (!email || root.querySelector('.form__response')) return Boolean(email);
|
|
45
|
+
const style = document.createElement('style');
|
|
46
|
+
style.textContent = RESPONSE_STYLE;
|
|
47
|
+
const box = document.createElement('input');
|
|
48
|
+
box.type = 'checkbox';
|
|
49
|
+
box.name = 'response';
|
|
50
|
+
box.addEventListener('change', () => {
|
|
51
|
+
email.required = box.checked;
|
|
52
|
+
onChange(box.checked);
|
|
53
|
+
});
|
|
54
|
+
const label = document.createElement('label');
|
|
55
|
+
label.className = 'form__response';
|
|
56
|
+
label.append(box, RESPONSE_LABEL);
|
|
57
|
+
(email.closest('label') ?? email).after(label);
|
|
58
|
+
root.append(style);
|
|
59
|
+
return true;
|
|
29
60
|
}
|
|
30
61
|
|
|
31
62
|
// A path as a pattern: each id in it becomes ":id", so every invoice's page
|
|
@@ -101,7 +132,9 @@ export async function startMonitoring({ flow, person }) {
|
|
|
101
132
|
beforeSendSpan: scrubSpan,
|
|
102
133
|
initialScope: { tags: config.tags ?? {} },
|
|
103
134
|
});
|
|
104
|
-
|
|
135
|
+
// Read when the report is sent: the tick box of the form open now.
|
|
136
|
+
let wantsResponse = false;
|
|
137
|
+
Sentry.getClient()?.on('beforeSendFeedback', (event) => tagNotify(event, wantsResponse));
|
|
105
138
|
// Who reported or met the error, so the lab can follow up.
|
|
106
139
|
Sentry.setUser({ id: person.id, email: person.email, username: person.name || undefined });
|
|
107
140
|
const feedback = config.feedback ? Sentry.getFeedback() : null;
|
|
@@ -112,8 +145,18 @@ export async function startMonitoring({ flow, person }) {
|
|
|
112
145
|
let form = null;
|
|
113
146
|
return async () => {
|
|
114
147
|
form?.removeFromDom();
|
|
148
|
+
wantsResponse = false;
|
|
115
149
|
form = await feedback.createForm();
|
|
116
150
|
form.appendToDom();
|
|
117
151
|
form.open();
|
|
152
|
+
// The form draws itself a moment after open(); add the tick box once its
|
|
153
|
+
// email field is there.
|
|
154
|
+
const root = document.getElementById('sentry-feedback')?.shadowRoot;
|
|
155
|
+
const onChange = (checked) => { wantsResponse = checked; };
|
|
156
|
+
if (!addResponseBox(root, onChange) && root) {
|
|
157
|
+
const watch = new MutationObserver(() => { if (addResponseBox(root, onChange)) watch.disconnect(); });
|
|
158
|
+
watch.observe(root, { childList: true, subtree: true });
|
|
159
|
+
setTimeout(() => watch.disconnect(), 5000);
|
|
160
|
+
}
|
|
118
161
|
};
|
|
119
162
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The pages of a long list, in the list window's foot bar (listFrame), as
|
|
2
|
+
// HubSpot's: rows per page, "1–100 of 1,204", and numbered pages between
|
|
3
|
+
// previous and next. The server cuts the page (GET /api/invoicing/invoices
|
|
4
|
+
// takes offset and limit), so a list works with any number of rows.
|
|
5
|
+
//
|
|
6
|
+
// listPager(h, { label, sizes, change }) answers { element, draw(page) }.
|
|
7
|
+
// page = { offset, limit, total }. change({ offset, limit }) asks for a
|
|
8
|
+
// page; a new size starts again at the first page. One page or none hides
|
|
9
|
+
// the page numbers; the size stays, so a person can show more per page.
|
|
10
|
+
// Issue #220 brings the pager over one record (.fx-pager) into this file.
|
|
11
|
+
|
|
12
|
+
export const PAGE_SIZES = Object.freeze([25, 50, 100, 200]);
|
|
13
|
+
|
|
14
|
+
const chevron = (d) => `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${d}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
15
|
+
const PREV = chevron('M14.5 6l-6 6 6 6');
|
|
16
|
+
const NEXT = chevron('M9.5 6l6 6-6 6');
|
|
17
|
+
const count = new Intl.NumberFormat('en-AU');
|
|
18
|
+
|
|
19
|
+
// The page numbers to show: the first, the last, and two each side of the
|
|
20
|
+
// one shown, with '…' for a gap. Pages count from 1.
|
|
21
|
+
export function pageNumbers(at, pages) {
|
|
22
|
+
if (pages <= 7) return Array.from({ length: pages }, (_, n) => n + 1);
|
|
23
|
+
const near = new Set([1, pages, at - 1, at, at + 1]);
|
|
24
|
+
if (at <= 3) [2, 3, 4].forEach((n) => near.add(n));
|
|
25
|
+
if (at >= pages - 2) [pages - 3, pages - 2, pages - 1].forEach((n) => near.add(n));
|
|
26
|
+
const list = [...near].filter((n) => n >= 1 && n <= pages).sort((a, b) => a - b);
|
|
27
|
+
return list.flatMap((n, i) => (i && n - list[i - 1] > 1 ? ['…', n] : [n]));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// "1–100 of 1,204", or "0 of 0".
|
|
31
|
+
export function pageRange({ offset, limit, total }) {
|
|
32
|
+
if (!total) return '0 of 0';
|
|
33
|
+
return `${count.format(offset + 1)}–${count.format(Math.min(offset + limit, total))} of ${count.format(total)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function listPager(h, { label = 'Pages', sizes = PAGE_SIZES, change }) {
|
|
37
|
+
const element = h('nav', { class: 'fx-pages', 'aria-label': label });
|
|
38
|
+
const icon = (markup) => { const span = h('span', { 'aria-hidden': 'true' }); span.innerHTML = markup; return span; };
|
|
39
|
+
|
|
40
|
+
function draw({ offset = 0, limit = sizes[0], total = 0 }) {
|
|
41
|
+
const pages = Math.max(1, Math.ceil(total / limit));
|
|
42
|
+
const at = Math.min(pages, Math.floor(offset / limit) + 1);
|
|
43
|
+
const go = (n) => change({ offset: (n - 1) * limit, limit });
|
|
44
|
+
const size = h('select', {
|
|
45
|
+
class: 'fx-select fx-pages__size', 'aria-label': 'Rows per page', title: 'Rows per page',
|
|
46
|
+
onchange: (event) => change({ offset: 0, limit: Number(event.target.value) }),
|
|
47
|
+
}, sizes.map((n) => h('option', { value: String(n), ...(n === limit ? { selected: true } : {}) }, String(n))));
|
|
48
|
+
const step = (n, markup, name) => h('button', {
|
|
49
|
+
type: 'button', class: 'fx-pages__step', 'aria-label': name, title: name,
|
|
50
|
+
...(n < 1 || n > pages ? { disabled: true } : {}), onclick: () => go(n),
|
|
51
|
+
}, icon(markup));
|
|
52
|
+
element.replaceChildren(
|
|
53
|
+
h('label', { class: 'fx-pages__rows' }, 'Rows', size),
|
|
54
|
+
h('span', { class: 'fx-pages__range' }, pageRange({ offset, limit, total })),
|
|
55
|
+
pages > 1 ? h('span', { class: 'fx-pages__steps' },
|
|
56
|
+
step(at - 1, PREV, 'Previous page'),
|
|
57
|
+
pageNumbers(at, pages).map((n) => (n === '…'
|
|
58
|
+
? h('span', { class: 'fx-pages__gap', 'aria-hidden': 'true' }, '…')
|
|
59
|
+
: h('button', {
|
|
60
|
+
type: 'button', class: 'fx-pages__num', 'aria-label': `Page ${n}`,
|
|
61
|
+
...(n === at ? { 'aria-current': 'page' } : {}), onclick: () => { if (n !== at) go(n); },
|
|
62
|
+
}, String(n)))),
|
|
63
|
+
step(at + 1, NEXT, 'Next page')) : '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { element, draw };
|
|
67
|
+
}
|
|
@@ -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
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// records need a person and opens the work. Everyone can read it; an owner or
|
|
8
8
|
// admin changes it. The caller passes in app.js's shared pieces as `ui`.
|
|
9
9
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
10
|
+
import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
11
|
+
import { normaliseListView, PRICE_COLUMNS } from '/assets/ui/job-view.js';
|
|
10
12
|
import { reconcileRows } from '/assets/ui/price-match.js';
|
|
11
13
|
|
|
12
14
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/PRICE_LIST.md';
|
|
@@ -16,6 +18,15 @@ const LINE_PRICES = [
|
|
|
16
18
|
{ value: 'list', label: 'Price list' },
|
|
17
19
|
];
|
|
18
20
|
|
|
21
|
+
// The list's columns (columns.js): each keeps its own width, saved with the
|
|
22
|
+
// person's account; + shows a hidden one again.
|
|
23
|
+
const COLUMN_DEFS = {
|
|
24
|
+
code: { kind: 'number', className: 'fx-price__code', cell: (item) => item.code },
|
|
25
|
+
name: { kind: 'client', cell: (item) => item.name },
|
|
26
|
+
price: { kind: 'total', num: true, cell: (item) => money(item.unitAmountCents) },
|
|
27
|
+
tax: { kind: 'case', className: 'fx-dim', cell: (item) => TAX_LABELS[item.taxCode] ?? item.taxCode },
|
|
28
|
+
};
|
|
29
|
+
|
|
19
30
|
const money = (cents) => (cents === null || cents === undefined ? '' : `$${(cents / 100).toFixed(2)}`);
|
|
20
31
|
const toCents = (text) => {
|
|
21
32
|
const value = Number(String(text ?? '').replace(/[$,\s]/g, ''));
|
|
@@ -26,6 +37,18 @@ export async function renderPriceList(main, ui) {
|
|
|
26
37
|
const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
|
|
27
38
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
28
39
|
const host = h('div', { class: 'fx-price' }, h('div', { class: 'fx-skeleton' }));
|
|
40
|
+
let view = normaliseListView(me?.views?.['price-list'], PRICE_COLUMNS);
|
|
41
|
+
const saveView = viewSaver(ui, 'price-list');
|
|
42
|
+
const heads = columnTable(h, {
|
|
43
|
+
order: () => view.columns,
|
|
44
|
+
widths: () => view.widths,
|
|
45
|
+
change: (patch) => {
|
|
46
|
+
view = normaliseListView({ ...view, ...patch }, PRICE_COLUMNS);
|
|
47
|
+
saveView(view);
|
|
48
|
+
draw();
|
|
49
|
+
},
|
|
50
|
+
hidden: () => Object.keys(PRICE_COLUMNS).filter((id) => !view.columns.includes(id)).map((id) => ({ id, label: PRICE_COLUMNS[id] })),
|
|
51
|
+
});
|
|
29
52
|
const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Add code');
|
|
30
53
|
const reconcileButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', hidden: true, onclick: () => ui.go('/price-list/reconcile') }, 'Reconcile');
|
|
31
54
|
const alerts = alertStrip(ui, 'price-list');
|
|
@@ -160,15 +183,12 @@ export async function renderPriceList(main, ui) {
|
|
|
160
183
|
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' }, [['active', 'Active', live.length], ['archived', 'Archived', archived.length]].map(([id, label, count]) => h('button', {
|
|
161
184
|
type: 'button', role: 'tab', 'aria-selected': String(tab === id), onclick: () => { tab = id; draw(); },
|
|
162
185
|
}, label, h('span', { class: 'fx-tabs__count' }, String(count)))));
|
|
186
|
+
const columns = view.columns.map((id) => ({ id, label: PRICE_COLUMNS[id], ...COLUMN_DEFS[id] }));
|
|
163
187
|
const itemRow = (item) => h('tr', { onclick: () => edit(item), tabindex: '0', onkeydown: (event) => { if (event.key === 'Enter') edit(item); } },
|
|
164
|
-
h
|
|
165
|
-
h('td', {}
|
|
166
|
-
h('td', { class: 'fx-num' }, money(item.unitAmountCents)),
|
|
167
|
-
h('td', { class: 'fx-dim' }, TAX_LABELS[item.taxCode] ?? item.taxCode));
|
|
188
|
+
rowCells(h, columns, item),
|
|
189
|
+
h('td', { class: 'fx-c-end' }));
|
|
168
190
|
const table = shown.length
|
|
169
|
-
? h('div', { class: 'fx-
|
|
170
|
-
h('thead', {}, h('tr', {}, h('th', {}, 'Code'), h('th', {}, 'Name'), h('th', { class: 'fx-num' }, 'Price'), h('th', {}, 'Tax'))),
|
|
171
|
-
h('tbody', {}, shown.map(itemRow))))
|
|
191
|
+
? h('div', { class: 'fx-panel' }, heads.table({ className: 'fx-table--rows fx-price__table', columns, rows: h('tbody', {}, shown.map(itemRow)) }))
|
|
172
192
|
: h('p', { class: 'fx-empty' }, words ? 'No code matches.' : tab === 'archived' ? 'No archived codes.' : 'No codes yet. Add the first one.');
|
|
173
193
|
const sections = [
|
|
174
194
|
disabled ? notice('Only an owner or admin can change these.', 'info') : null,
|