@arcforgelabs/flow 2026.10.50 → 2026.10.52
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/SECURITY.md +9 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/captures.js +131 -0
- package/server/api/invoicing.js +1 -1
- package/server/app.js +15 -0
- package/server/auth/people.js +3 -3
- package/server/core/ai-bedrock.js +124 -0
- package/server/core/ai.js +145 -0
- package/server/core/capture-match.js +158 -0
- package/server/core/captures.js +501 -0
- package/server/core/invoice-rules.js +16 -5
- package/server/core/reader.js +324 -0
- package/server/core/sending.js +7 -3
- package/server/core/storage.js +491 -0
- package/server/db/migrations/0023_captures.sql +46 -0
- package/server/db/migrations/0024_rule_icon.sql +5 -0
- package/server/db/migrations/meta/0023_snapshot.json +4693 -0
- package/server/db/migrations/meta/0024_snapshot.json +4703 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +50 -0
- package/server/index.js +47 -1
- package/server/integrations/seazona/history/backfill.js +39 -9
- package/server/integrations/seazona/index.js +1 -12
- package/server/integrations/seazona/stage-moves.js +3 -13
- package/server/integrations/sentry/index.js +6 -6
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/drafts.js +27 -13
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +101 -8
- package/server/ui/capture-fields.js +17 -0
- package/server/ui/captures.js +283 -0
- package/server/ui/columns.js +68 -3
- package/server/ui/flow.css +92 -17
- package/server/ui/invoice-rules.js +10 -2
- package/server/ui/invoicing-settings.js +2 -8
- package/server/ui/invoicing.js +12 -8
- package/server/ui/job-view.js +6 -3
- package/server/ui/jobs.js +40 -117
- package/server/ui/kit.js +86 -1
- package/server/ui/list-views.js +47 -3
- package/server/ui/mobile.js +45 -5
- package/server/ui/nav-pins.js +4 -2
- package/server/ui/price-list.js +138 -69
- package/server/ui/routes.js +4 -1
- package/server/ui/saved-views.js +2 -0
- package/server/ui/view-bar.js +61 -2
package/server/ui/kit.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The shared pieces every Flow page builds with: elements (h), the page
|
|
2
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(),
|
|
3
|
+
// that waits between presses, cards that open a page, the icon picker, and the crumbs trail. app.js hands them to each view module in ui(),
|
|
4
4
|
// and the design system's cards (scripts/design-system/) render these same
|
|
5
5
|
// functions, so a card is the real thing. Reuse one or give it an option;
|
|
6
6
|
// never copy it into a page (AGENTS.md, "UI: reuse first").
|
|
@@ -499,6 +499,91 @@ export function tileSection(title, cards, { href, more = 'Show all' } = {}) {
|
|
|
499
499
|
h('div', { class: 'fx-tiles__grid' }, cards)), 'tileSection');
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
+
// The icons an icon picker shows before a search. A search finds any Lucide
|
|
503
|
+
// icon by its name or its words (lucide-static's tags.json).
|
|
504
|
+
export const ICON_CHOICES = Object.freeze([
|
|
505
|
+
'flag', 'clock', 'ban', 'list-checks', 'circle-alert', 'triangle-alert', 'octagon-alert', 'shield-alert', 'bell', 'bell-ring',
|
|
506
|
+
'info', 'circle-help', 'circle-check', 'circle-x', 'check', 'x', 'eye', 'eye-off', 'lock', 'unlock',
|
|
507
|
+
'star', 'heart', 'bookmark', 'tag', 'tags', 'hash', 'percent', 'dollar-sign', 'banknote', 'receipt',
|
|
508
|
+
'receipt-text', 'wallet', 'credit-card', 'coins', 'calculator', 'piggy-bank', 'hand-coins', 'file-text', 'file-check', 'file-x',
|
|
509
|
+
'file-warning', 'files', 'folder', 'clipboard-list', 'clipboard-check', 'notebook-pen', 'pencil', 'pen-line', 'stamp', 'send',
|
|
510
|
+
'mail', 'inbox', 'archive', 'calendar', 'calendar-clock', 'calendar-x', 'calendar-check', 'hourglass', 'timer', 'alarm-clock',
|
|
511
|
+
'history', 'repeat', 'refresh-cw', 'user', 'user-round', 'users', 'user-check', 'user-x', 'contact', 'building',
|
|
512
|
+
'building-2', 'hospital', 'house', 'map-pin', 'truck', 'package', 'box', 'boxes', 'shopping-cart', 'store',
|
|
513
|
+
'briefcase', 'wrench', 'hammer', 'cog', 'sliders-horizontal', 'filter', 'search', 'scan-search', 'scale', 'gavel',
|
|
514
|
+
'shield', 'shield-check', 'badge-check', 'award', 'thumbs-up', 'thumbs-down', 'smile', 'frown', 'zap', 'sparkles',
|
|
515
|
+
'flame', 'snowflake', 'sun', 'moon', 'droplet', 'leaf', 'stethoscope', 'syringe', 'pill', 'microscope',
|
|
516
|
+
'test-tube', 'flask-conical', 'activity', 'heart-pulse', 'bone', 'hand', 'camera', 'image', 'printer', 'phone',
|
|
517
|
+
'message-square', 'link', 'unlink', 'globe', 'pause', 'play', 'circle-stop', 'octagon-x', 'rocket', 'target',
|
|
518
|
+
'lightbulb', 'puzzle', 'layers', 'list', 'split', 'merge', 'git-branch', 'text-cursor-input', 'type', 'skull',
|
|
519
|
+
]);
|
|
520
|
+
const ICON_FOUND = 96;
|
|
521
|
+
let iconWords = null;
|
|
522
|
+
let pickerNext = 0;
|
|
523
|
+
|
|
524
|
+
// An icon picker (IconPicker card): a button that shows the icon now chosen,
|
|
525
|
+
// and a panel with a search box and a grid of icons, each named on hover. On
|
|
526
|
+
// a desk the panel opens under the button; on a phone it is a sheet at the
|
|
527
|
+
// foot. `value` null shows `fallback`, and "Default" goes back to it.
|
|
528
|
+
// `onpick(name)` gets each choice (null for Default).
|
|
529
|
+
export function iconPicker({ value = null, fallback = 'circle', label = 'Icon', onpick = () => {} }) {
|
|
530
|
+
let current = value;
|
|
531
|
+
let shown = fallback;
|
|
532
|
+
const id = `fx-icons-${++pickerNext}`;
|
|
533
|
+
const button = h('button', {
|
|
534
|
+
class: 'fx-icons__button', type: 'button', 'aria-haspopup': 'dialog', popovertarget: id,
|
|
535
|
+
});
|
|
536
|
+
const find = h('input', { class: 'fx-input', type: 'search', placeholder: 'Find', 'aria-label': 'Find an icon' });
|
|
537
|
+
const grid = h('div', { class: 'fx-icons__grid', role: 'group', 'aria-label': label });
|
|
538
|
+
const reset = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Default');
|
|
539
|
+
const panel = applyPhoneRule(h('div', { class: 'fx-pop fx-icons__panel', id, popover: 'auto', role: 'dialog', 'aria-label': label },
|
|
540
|
+
find, grid, h('div', { class: 'fx-icons__foot' }, reset)), 'iconPicker');
|
|
541
|
+
const draw = () => {
|
|
542
|
+
named(button, `${label}: ${current ?? shown}`);
|
|
543
|
+
button.replaceChildren(lucide(h, current ?? shown), lucide(h, 'chevron-down'));
|
|
544
|
+
};
|
|
545
|
+
const choose = (name) => {
|
|
546
|
+
panel.hidePopover();
|
|
547
|
+
current = name;
|
|
548
|
+
draw();
|
|
549
|
+
onpick(name);
|
|
550
|
+
};
|
|
551
|
+
const cell = (name) => named(h('button', {
|
|
552
|
+
class: 'fx-icons__cell', type: 'button', 'aria-pressed': String(name === (current ?? shown)), onclick: () => choose(name),
|
|
553
|
+
}, lucide(h, name)), name);
|
|
554
|
+
const fill = () => {
|
|
555
|
+
const q = find.value.trim().toLowerCase();
|
|
556
|
+
const words = iconWords ?? Object.fromEntries(ICON_CHOICES.map((n) => [n, []]));
|
|
557
|
+
const names = q ? Object.keys(words).filter((n) => n.includes(q) || words[n].some((w) => w.includes(q))).slice(0, ICON_FOUND) : ICON_CHOICES;
|
|
558
|
+
grid.replaceChildren(...(names.length ? names.map(cell) : [h('p', { class: 'fx-dim' }, 'No icon.')]));
|
|
559
|
+
};
|
|
560
|
+
find.addEventListener('input', fill);
|
|
561
|
+
reset.addEventListener('click', () => choose(null));
|
|
562
|
+
panel.addEventListener('toggle', async (event) => {
|
|
563
|
+
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
564
|
+
if (event.newState !== 'open') return;
|
|
565
|
+
find.value = '';
|
|
566
|
+
reset.hidden = current === null;
|
|
567
|
+
fill();
|
|
568
|
+
if (panel.dataset.phoneLayout !== 'sheet') {
|
|
569
|
+
const at = button.getBoundingClientRect();
|
|
570
|
+
panel.style.top = `${Math.round(Math.max(8, Math.min(at.bottom + 6, innerHeight - panel.offsetHeight - 8)))}px`;
|
|
571
|
+
panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
572
|
+
find.focus();
|
|
573
|
+
}
|
|
574
|
+
// The words of every Lucide icon, once. Without them (the design system's
|
|
575
|
+
// page), a search reads the names here.
|
|
576
|
+
iconWords ??= await fetch('/assets/icons/tags.json').then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
|
577
|
+
if (iconWords && find.value) fill();
|
|
578
|
+
});
|
|
579
|
+
draw();
|
|
580
|
+
return {
|
|
581
|
+
element: h('span', { class: 'fx-icons' }, button, panel),
|
|
582
|
+
value: () => current,
|
|
583
|
+
fallback: (name) => { shown = name; draw(); },
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
502
587
|
// A dialog that fills the screen has no pixel outside it, so an accessibility
|
|
503
588
|
// check cannot tell it is modal and reads the page underneath. While one is
|
|
504
589
|
// open, every other top-level node is inert. The attribute is what the check
|
package/server/ui/list-views.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// A person's saved views of a list page (Jobs, Invoices, Reconcile
|
|
1
|
+
// A person's saved views of a list page (Jobs, Invoices, Reconcile, Price
|
|
2
|
+
// list), as
|
|
2
3
|
// ClickUp's view tabs: each has a name and the page's view (Group, Sort,
|
|
3
4
|
// Filter, Columns), and one can be the person's default, which opens when
|
|
4
5
|
// they go to the page. No DOM: the server checks a saved list with the same
|
|
5
6
|
// rules the page uses (server/auth/people.js).
|
|
6
7
|
import { caseNumber } from './job-board.js';
|
|
7
|
-
import { cleanFilterView, fieldIdOf, filterJobs, KIND_OF_TYPE, normaliseJobsView } from './job-view.js';
|
|
8
|
+
import { cleanFilterView, fieldIdOf, filterJobs, KIND_OF_TYPE, normaliseJobsView, normaliseListView, PRICE_COLUMNS } from './job-view.js';
|
|
8
9
|
|
|
9
10
|
// The Invoices filter's fields (invoicing.js): the list's own columns, then
|
|
10
11
|
// the job columns the Columns menu offers (Case, Practitioner, Shipped).
|
|
@@ -111,6 +112,48 @@ export const RECONCILE_FILTER_FIELDS = {
|
|
|
111
112
|
match: { label: 'Match', kind: 'check', icon: 'link' },
|
|
112
113
|
};
|
|
113
114
|
|
|
115
|
+
// The Price list's fields (price-list.js): Filter, Group and Sort read
|
|
116
|
+
// them. `group` marks the fields a person can group by.
|
|
117
|
+
export const PRICE_FIELDS = {
|
|
118
|
+
code: { label: 'Code', kind: 'text', icon: 'hash' },
|
|
119
|
+
name: { label: 'Name', kind: 'text', icon: 'text' },
|
|
120
|
+
price: { label: 'Price', kind: 'number', icon: 'dollar-sign' },
|
|
121
|
+
tax: { label: 'Tax', kind: 'choice', icon: 'percent', group: true },
|
|
122
|
+
linked: { label: 'Linked', kind: 'choice', icon: 'link', group: true },
|
|
123
|
+
differs: { label: 'Differs', kind: 'check', icon: 'triangle-alert', group: true },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// A code's value for a Price list field, as Filter, Group and Sort compare
|
|
127
|
+
// it. Linked is the systems the code links to (a list: a code can link to
|
|
128
|
+
// Xero and Seazona both).
|
|
129
|
+
export function priceValue(item, field) {
|
|
130
|
+
switch (field) {
|
|
131
|
+
case 'code': return item.code ?? null;
|
|
132
|
+
case 'name': return item.name ?? null;
|
|
133
|
+
case 'price': return item.unitAmountCents == null ? null : item.unitAmountCents / 100;
|
|
134
|
+
case 'tax': return item.taxCode ?? null;
|
|
135
|
+
case 'linked': return [...new Set((item.links ?? []).map((l) => l.system))];
|
|
136
|
+
case 'differs': return (item.findings?.length ?? 0) > 0;
|
|
137
|
+
default: return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The Price list view: its columns (as before), Group, Sort and Filter.
|
|
142
|
+
const PRICE_DIRS = ['asc', 'desc'];
|
|
143
|
+
export function normalisePriceView(input) {
|
|
144
|
+
const value = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
return {
|
|
147
|
+
...normaliseListView(value, PRICE_COLUMNS),
|
|
148
|
+
group: PRICE_FIELDS[value.group]?.group ? value.group : 'none',
|
|
149
|
+
groupDir: PRICE_DIRS.includes(value.groupDir) ? value.groupDir : 'asc',
|
|
150
|
+
sorts: (Array.isArray(value.sorts) ? value.sorts : [])
|
|
151
|
+
.filter((s) => s && Object.hasOwn(PRICE_FIELDS, s.by) && !seen.has(s.by) && seen.add(s.by))
|
|
152
|
+
.map((s) => ({ by: s.by, dir: PRICE_DIRS.includes(s.dir) ? s.dir : 'asc' })).slice(0, 5),
|
|
153
|
+
...cleanFilterView(value, kindIn(PRICE_FIELDS, false)),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
114
157
|
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;
|
|
115
158
|
const kindIn = (fields, custom) => (field) => (Object.hasOwn(fields, field) ? fields[field].kind : custom && CUSTOM_FIELD.test(field) ? '*' : null);
|
|
116
159
|
|
|
@@ -120,12 +163,13 @@ export const normaliseReconcileView = (value) => cleanFilterView(value, kindIn(R
|
|
|
120
163
|
|
|
121
164
|
// The pages with saved views. Each is named in a switch, never looked up
|
|
122
165
|
// by the name a request sends, so a name such as "constructor" is refused.
|
|
123
|
-
export const SAVED_VIEW_PAGES = Object.freeze(['jobs', 'invoices', 'reconcile']);
|
|
166
|
+
export const SAVED_VIEW_PAGES = Object.freeze(['jobs', 'invoices', 'reconcile', 'price-list']);
|
|
124
167
|
export function pageViewCheck(page) {
|
|
125
168
|
switch (page) {
|
|
126
169
|
case 'jobs': return normaliseJobsView;
|
|
127
170
|
case 'invoices': return normaliseInvoicesView;
|
|
128
171
|
case 'reconcile': return normaliseReconcileView;
|
|
172
|
+
case 'price-list': return normalisePriceView;
|
|
129
173
|
default: return null;
|
|
130
174
|
}
|
|
131
175
|
}
|
package/server/ui/mobile.js
CHANGED
|
@@ -68,6 +68,24 @@ export const RULES = {
|
|
|
68
68
|
"mark": false,
|
|
69
69
|
"card": "Button"
|
|
70
70
|
},
|
|
71
|
+
"captureButton": {
|
|
72
|
+
"component": "captureButton",
|
|
73
|
+
"direction": "button",
|
|
74
|
+
"phone": {
|
|
75
|
+
"layout": "camera"
|
|
76
|
+
},
|
|
77
|
+
"tablet": {
|
|
78
|
+
"layout": "camera"
|
|
79
|
+
},
|
|
80
|
+
"desk": {
|
|
81
|
+
"layout": "files"
|
|
82
|
+
},
|
|
83
|
+
"touch": 44,
|
|
84
|
+
"pageOverflow": "none",
|
|
85
|
+
"screens": [],
|
|
86
|
+
"mark": false,
|
|
87
|
+
"card": "CaptureButton"
|
|
88
|
+
},
|
|
71
89
|
"cardRows": {
|
|
72
90
|
"component": "cardRows",
|
|
73
91
|
"direction": "card per row",
|
|
@@ -203,7 +221,8 @@ export const RULES = {
|
|
|
203
221
|
"invoices-approved",
|
|
204
222
|
"invoices-ticked",
|
|
205
223
|
"invoices-filter",
|
|
206
|
-
"reconcile"
|
|
224
|
+
"reconcile",
|
|
225
|
+
"price-list"
|
|
207
226
|
],
|
|
208
227
|
"card": "Filter"
|
|
209
228
|
},
|
|
@@ -230,7 +249,8 @@ export const RULES = {
|
|
|
230
249
|
"invoices-approved",
|
|
231
250
|
"invoices-ticked",
|
|
232
251
|
"invoices-filter",
|
|
233
|
-
"reconcile"
|
|
252
|
+
"reconcile",
|
|
253
|
+
"price-list"
|
|
234
254
|
],
|
|
235
255
|
"card": "FindBox"
|
|
236
256
|
},
|
|
@@ -270,6 +290,23 @@ export const RULES = {
|
|
|
270
290
|
"mark": false,
|
|
271
291
|
"card": "Icon"
|
|
272
292
|
},
|
|
293
|
+
"iconPicker": {
|
|
294
|
+
"component": "iconPicker",
|
|
295
|
+
"direction": "sheet",
|
|
296
|
+
"phone": {
|
|
297
|
+
"layout": "sheet"
|
|
298
|
+
},
|
|
299
|
+
"tablet": {
|
|
300
|
+
"layout": "popover"
|
|
301
|
+
},
|
|
302
|
+
"desk": {
|
|
303
|
+
"layout": "popover"
|
|
304
|
+
},
|
|
305
|
+
"touch": 44,
|
|
306
|
+
"pageOverflow": "none",
|
|
307
|
+
"screens": [],
|
|
308
|
+
"card": "IconPicker"
|
|
309
|
+
},
|
|
273
310
|
"jobChangeBanner": {
|
|
274
311
|
"component": "jobChangeBanner",
|
|
275
312
|
"direction": "banner",
|
|
@@ -311,7 +348,8 @@ export const RULES = {
|
|
|
311
348
|
"invoices-approved",
|
|
312
349
|
"invoices-ticked",
|
|
313
350
|
"invoices-filter",
|
|
314
|
-
"reconcile"
|
|
351
|
+
"reconcile",
|
|
352
|
+
"price-list"
|
|
315
353
|
],
|
|
316
354
|
"card": "ListFrame"
|
|
317
355
|
},
|
|
@@ -339,7 +377,8 @@ export const RULES = {
|
|
|
339
377
|
"invoices-approved",
|
|
340
378
|
"invoices-ticked",
|
|
341
379
|
"invoices-filter",
|
|
342
|
-
"reconcile"
|
|
380
|
+
"reconcile",
|
|
381
|
+
"price-list"
|
|
343
382
|
],
|
|
344
383
|
"card": "ListHead"
|
|
345
384
|
},
|
|
@@ -534,7 +573,8 @@ export const RULES = {
|
|
|
534
573
|
"invoices-approved",
|
|
535
574
|
"invoices-ticked",
|
|
536
575
|
"invoices-filter",
|
|
537
|
-
"reconcile"
|
|
576
|
+
"reconcile",
|
|
577
|
+
"price-list"
|
|
538
578
|
],
|
|
539
579
|
"card": "SavedViews"
|
|
540
580
|
},
|
package/server/ui/nav-pins.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
export const NAV_PAGES = Object.freeze([
|
|
7
7
|
'jobs',
|
|
8
|
+
'captures',
|
|
8
9
|
'invoices',
|
|
9
10
|
'contacts',
|
|
10
11
|
'reconcile',
|
|
@@ -17,11 +18,12 @@ export const NAV_PAGES = Object.freeze([
|
|
|
17
18
|
|
|
18
19
|
export const NAV_LOCKED = Object.freeze(['jobs', 'invoices']);
|
|
19
20
|
|
|
20
|
-
// Today's sidebar, before a person has chosen: daily work (Jobs,
|
|
21
|
-
// Contacts, Reconcile), Price list and Rules. Automations, Custom fields
|
|
21
|
+
// Today's sidebar, before a person has chosen: daily work (Jobs, Capture,
|
|
22
|
+
// Invoices, Contacts, Reconcile), Price list and Rules. Automations, Custom fields
|
|
22
23
|
// and Integrations start in More.
|
|
23
24
|
export const NAV_DEFAULT = Object.freeze([
|
|
24
25
|
'jobs',
|
|
26
|
+
'captures',
|
|
25
27
|
'invoices',
|
|
26
28
|
'contacts',
|
|
27
29
|
'reconcile',
|
package/server/ui/price-list.js
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
1
|
+
// Price list (/price-list; server/core/price-list.js, docs/PRICE_LIST.md).
|
|
2
|
+
// Flow's own codes and prices, which work with no integration connected.
|
|
3
|
+
// Laid out like Jobs and Invoices: the shared list head (Active / Archived
|
|
4
|
+
// tabs, Group, Sort, Filter, saved views, Find) over the list window, one
|
|
5
|
+
// row per code. A row opens a dialog with the code's fields, its links and,
|
|
6
|
+
// when something differs, Mark resolved. The head's icons open Reconcile
|
|
7
|
+
// (price-reconcile.js, while an integration with its own price list is on)
|
|
8
|
+
// and the price list's settings. Everyone can read it; an owner or admin
|
|
9
|
+
// changes it. The caller passes in app.js's shared pieces as `ui`.
|
|
9
10
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
10
|
-
import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
11
|
-
import {
|
|
11
|
+
import { columnTable, groupHead, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
12
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
13
|
+
import { filterJobs, groupJobs, PRICE_COLUMNS, sortJobs } from '/assets/ui/job-view.js';
|
|
14
|
+
import { normalisePriceView, PRICE_FIELDS, priceValue } from '/assets/ui/list-views.js';
|
|
12
15
|
import { reconcileRows } from '/assets/ui/price-match.js';
|
|
16
|
+
import { savedViews } from '/assets/ui/saved-views.js';
|
|
17
|
+
import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
|
|
13
18
|
|
|
14
|
-
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/PRICE_LIST.md';
|
|
15
19
|
const TAX_LABELS = { GST: 'GST', GST_FREE: 'GST free' };
|
|
20
|
+
// Where an invoice line takes its price (the line mapping routine's
|
|
21
|
+
// `linePrice`): the job's own amount, or this list's price.
|
|
16
22
|
const LINE_PRICES = [
|
|
17
|
-
{ value: 'work', label: '
|
|
23
|
+
{ value: 'work', label: 'Job price' },
|
|
18
24
|
{ value: 'list', label: 'Price list' },
|
|
19
25
|
];
|
|
26
|
+
const NUMBER_WORDS = ['Low to high', 'High to low'];
|
|
20
27
|
|
|
21
28
|
// The list's columns (columns.js): each keeps its own width, saved with the
|
|
22
29
|
// person's account; + shows a hidden one again.
|
|
23
30
|
const COLUMN_DEFS = {
|
|
24
|
-
code: { kind: 'number', className: 'fx-price__code', cell: (item) => item.code },
|
|
31
|
+
code: { kind: 'number', phone: 'meta', className: 'fx-price__code', cell: (item) => item.code },
|
|
25
32
|
name: { kind: 'client', phone: 'title', cell: (item) => item.name },
|
|
26
33
|
price: { kind: 'total', num: true, cell: (item) => money(item.unitAmountCents) },
|
|
27
34
|
tax: { kind: 'case', className: 'fx-dim', cell: (item) => TAX_LABELS[item.taxCode] ?? item.taxCode },
|
|
@@ -34,31 +41,79 @@ const toCents = (text) => {
|
|
|
34
41
|
};
|
|
35
42
|
|
|
36
43
|
export async function renderPriceList(main, ui) {
|
|
37
|
-
const { h, flow, toast, notice, dialog,
|
|
44
|
+
const { h, flow, toast, notice, dialog, listHead, listFrame, me } = ui;
|
|
38
45
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
39
|
-
const host = h('div', { class: 'fx-
|
|
40
|
-
|
|
46
|
+
const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
47
|
+
// The person's view (list-views.js): columns, Group, Sort and Filter,
|
|
48
|
+
// kept with their account.
|
|
49
|
+
let view = normalisePriceView(me?.views?.['price-list']);
|
|
41
50
|
const saveView = viewSaver(ui, 'price-list');
|
|
51
|
+
function change(patch) {
|
|
52
|
+
view = normalisePriceView({ ...view, ...patch });
|
|
53
|
+
saveView(view);
|
|
54
|
+
views.draw();
|
|
55
|
+
if (data) draw();
|
|
56
|
+
}
|
|
57
|
+
const views = savedViews(ui, { page: 'price-list', current: () => view, apply: change });
|
|
58
|
+
const opening = views.opening();
|
|
59
|
+
if (opening) view = normalisePriceView(opening);
|
|
60
|
+
views.draw();
|
|
61
|
+
|
|
62
|
+
let data = null;
|
|
63
|
+
let query = '';
|
|
64
|
+
let tab = 'active';
|
|
65
|
+
let opened = new URLSearchParams(location.search).get('code');
|
|
66
|
+
const systemName = (id) => data?.systems.find((s) => s.id === id)?.name ?? id;
|
|
67
|
+
const taxName = (code) => TAX_LABELS[code] ?? code;
|
|
68
|
+
|
|
69
|
+
// Group, Sort and Filter: the same chips as on Jobs (view-bar.js).
|
|
70
|
+
const kindOf = (field) => PRICE_FIELDS[field]?.kind ?? null;
|
|
71
|
+
const fieldList = (keep = () => true) => Object.entries(PRICE_FIELDS).filter(([, f]) => keep(f)).map(([value, f]) => ({ value, label: f.label, icon: f.icon }));
|
|
72
|
+
const bar = viewBar(h);
|
|
73
|
+
const groupMenu = bar.groupMenu({ options: () => fieldList((f) => f.group), view: () => view, change });
|
|
74
|
+
const sortMenu = bar.sortMenu({
|
|
75
|
+
options: fieldList, view: () => view, change,
|
|
76
|
+
dirWords: (by) => (kindOf(by) === 'number' ? NUMBER_WORDS : ['A to Z', 'Z to A']),
|
|
77
|
+
nameOf: (by) => PRICE_FIELDS[by].label, empty: 'No sort. Codes show A to Z.',
|
|
78
|
+
});
|
|
79
|
+
const filterMenu = bar.filterMenu({
|
|
80
|
+
fields: fieldList, kindOf,
|
|
81
|
+
choicesOf: (field) => (field === 'tax' ? Object.entries(TAX_LABELS).map(([value, label]) => ({ value, label }))
|
|
82
|
+
: field === 'linked' ? (data?.systems ?? []).map((s) => ({ value: s.id, label: s.name })) : []),
|
|
83
|
+
view: () => view, change, first: 'tax', empty: 'No filters. Every code shows.',
|
|
84
|
+
});
|
|
85
|
+
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' }, ...groupMenu.node, ...sortMenu.node, ...filterMenu.node);
|
|
86
|
+
const drawControls = () => {
|
|
87
|
+
groupMenu.set(view.group === 'none' ? 'Group' : `Group: ${PRICE_FIELDS[view.group].label}`, view.group !== 'none', view.group === 'none' ? null : () => change({ group: 'none' }));
|
|
88
|
+
sortMenu.draw();
|
|
89
|
+
filterMenu.draw();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// The list's columns (columns.js): each keeps its own width, saved with
|
|
93
|
+
// the person's account; + shows a hidden one again; a head sorts.
|
|
42
94
|
const heads = columnTable(h, {
|
|
43
95
|
order: () => view.columns,
|
|
44
96
|
widths: () => view.widths,
|
|
45
|
-
change
|
|
46
|
-
view = normaliseListView({ ...view, ...patch }, PRICE_COLUMNS);
|
|
47
|
-
saveView(view);
|
|
48
|
-
draw();
|
|
49
|
-
},
|
|
97
|
+
change,
|
|
50
98
|
hidden: () => Object.keys(PRICE_COLUMNS).filter((id) => !view.columns.includes(id)).map((id) => ({ id, label: PRICE_COLUMNS[id] })),
|
|
99
|
+
sorting: { sortOf: (id) => (PRICE_FIELDS[id] ? id : null), sorts: () => view.sorts, change, dirWords: (by) => (kindOf(by) === 'number' ? NUMBER_WORDS : ['A to Z', 'Z to A']) },
|
|
51
100
|
});
|
|
52
|
-
const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Add code');
|
|
53
|
-
const reconcileButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', hidden: true, onclick: () => ui.go('/price-list/reconcile') }, 'Reconcile');
|
|
54
|
-
const alerts = alertStrip(ui, 'price-list');
|
|
55
|
-
main.append(pageHeader('Price list', { help: { href: DOCS, label: 'How the price list works' }, actions: [reconcileButton, addButton] }), alerts.element, host);
|
|
56
101
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
102
|
+
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
|
|
103
|
+
const find = findBox(h, { label: 'Find a code or name', value: query, oninput: (words) => { query = words; draw(); } });
|
|
104
|
+
const icon = (name, label, onclick) => h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': label, title: label, onclick }, lucide(h, name));
|
|
105
|
+
const reconcileButton = icon('git-compare-arrows', 'Reconcile', () => ui.go('/price-list/reconcile'));
|
|
106
|
+
reconcileButton.hidden = true;
|
|
107
|
+
const settingsButton = icon('settings', 'Price list settings', () => openSettings());
|
|
108
|
+
const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled, title: disabled ? 'Only an owner or admin can add a code' : null, onclick: () => edit(null) }, lucide(h, 'plus'), 'New');
|
|
109
|
+
const alerts = alertStrip(ui, 'price-list');
|
|
110
|
+
const listTop = listHead('Price list', {
|
|
111
|
+
actions: [reconcileButton, settingsButton, addButton],
|
|
112
|
+
views: views.element, start: tabs, controls, controlsAt: 'end', end: find.element, line: true, sticky: true,
|
|
113
|
+
});
|
|
114
|
+
const [title, ...top] = listTop.nodes;
|
|
115
|
+
const frame = listFrame(main, host);
|
|
116
|
+
main.append(title, alerts.element, ...top, frame.element);
|
|
62
117
|
|
|
63
118
|
async function send(path, method, payload, done = 'Saved.') {
|
|
64
119
|
try {
|
|
@@ -71,8 +126,6 @@ export async function renderPriceList(main, ui) {
|
|
|
71
126
|
}
|
|
72
127
|
}
|
|
73
128
|
|
|
74
|
-
const row = (label, hint, control) => settingRow({ label, hint, control });
|
|
75
|
-
|
|
76
129
|
const findingText = (f) => {
|
|
77
130
|
const name = systemName(f.system);
|
|
78
131
|
if (f.kind === 'price') return `${name} price is ${money(f.other)}. Flow price is ${money(f.flow)}.`;
|
|
@@ -148,55 +201,70 @@ export async function renderPriceList(main, ui) {
|
|
|
148
201
|
return load();
|
|
149
202
|
}
|
|
150
203
|
|
|
151
|
-
|
|
152
|
-
|
|
204
|
+
// The price list's settings: one dialog from the head's settings icon.
|
|
205
|
+
async function openSettings() {
|
|
206
|
+
const select = h('select', { class: 'fx-select', 'aria-label': 'Invoice line price', disabled }, LINE_PRICES.map((p) => h('option', { value: p.value }, p.label)));
|
|
207
|
+
let saved = 'work';
|
|
153
208
|
try {
|
|
154
209
|
const { automations } = await flow('/api/invoicing/automations');
|
|
155
|
-
|
|
156
|
-
} catch {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (await send('/api/invoicing/automations/map_lines', 'PUT', { config: { linePrice: select.value } })) saved = select.value;
|
|
163
|
-
else select.value = saved;
|
|
164
|
-
select.disabled = disabled;
|
|
210
|
+
saved = automations.find((a) => a.key === 'map_lines')?.config.linePrice ?? 'work';
|
|
211
|
+
} catch { /* the default shows */ }
|
|
212
|
+
select.value = saved;
|
|
213
|
+
const ok = await dialog({
|
|
214
|
+
title: 'Price list settings',
|
|
215
|
+
body: h('div', { class: 'fx-fields' }, h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Invoice line price'), select)),
|
|
216
|
+
confirm: 'Save',
|
|
165
217
|
});
|
|
166
|
-
|
|
218
|
+
if (ok && select.value !== saved) await send('/api/invoicing/automations/map_lines', 'PUT', { config: { linePrice: select.value } });
|
|
167
219
|
}
|
|
168
220
|
|
|
221
|
+
// A group's name: a choice's label, or Yes / No.
|
|
222
|
+
const groupLabel = (item) => {
|
|
223
|
+
const value = priceValue(item, view.group);
|
|
224
|
+
if (view.group === 'tax') return taxName(value);
|
|
225
|
+
if (view.group === 'linked') return value.length ? value.map(systemName).join(', ') : 'Not linked';
|
|
226
|
+
return value ? 'Differs' : 'Agrees';
|
|
227
|
+
};
|
|
228
|
+
|
|
169
229
|
function draw() {
|
|
230
|
+
drawControls();
|
|
170
231
|
const words = query.trim().toLowerCase();
|
|
171
232
|
const live = data.items.filter((i) => !i.archivedAt);
|
|
172
233
|
const archived = data.items.filter((i) => i.archivedAt);
|
|
173
|
-
const
|
|
234
|
+
const kept = filterJobs(tab === 'archived' ? archived : live, view, { valueOf: priceValue, kindOf, late: null, today: today() })
|
|
235
|
+
.filter((i) => !words || `${i.code} ${i.name}`.toLowerCase().includes(words));
|
|
236
|
+
const shown = sortJobs(kept, view, (item, by) => {
|
|
237
|
+
const value = priceValue(item, by);
|
|
238
|
+
return typeof value === 'string' ? value.toLowerCase() : value;
|
|
239
|
+
});
|
|
174
240
|
const todo = data.systems.length ? reconcileRows(data).length : 0;
|
|
175
241
|
reconcileButton.hidden = !data.systems.length;
|
|
176
|
-
reconcileButton.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a code', value: query, 'aria-label': 'Find a code' });
|
|
180
|
-
search.addEventListener('input', () => { query = search.value; draw(); search.focus(); search.setSelectionRange(query.length, query.length); });
|
|
181
|
-
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' }, [['active', 'Active', live.length], ['archived', 'Archived', archived.length]].map(([id, label, count]) => h('button', {
|
|
242
|
+
reconcileButton.title = todo ? `Reconcile: ${todo} to check` : 'Reconcile';
|
|
243
|
+
frame.count(`${shown.length} ${shown.length === 1 ? 'code' : 'codes'}`);
|
|
244
|
+
tabs.replaceChildren(...[['active', 'Active', live.length], ['archived', 'Archived', archived.length]].map(([id, label, count]) => h('button', {
|
|
182
245
|
type: 'button', role: 'tab', 'aria-selected': String(tab === id), onclick: () => { tab = id; draw(); },
|
|
183
246
|
}, label, h('span', { class: 'fx-tabs__count' }, String(count)))));
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
247
|
+
|
|
248
|
+
if (!shown.length) {
|
|
249
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, words || view.groups.length ? 'No code matches.' : tab === 'archived' ? 'No archived codes.' : 'No codes yet.'));
|
|
250
|
+
} else {
|
|
251
|
+
const columns = view.columns.map((id) => ({ id, label: PRICE_COLUMNS[id], ...COLUMN_DEFS[id] }));
|
|
252
|
+
const itemRow = (item) => h('tr', { class: 'fx-row-link', tabindex: '0', onclick: () => edit(item), onkeydown: (event) => { if (event.key === 'Enter') edit(item); } },
|
|
253
|
+
rowCells(h, columns, item),
|
|
254
|
+
h('td', { class: 'fx-c-end' }));
|
|
255
|
+
const groups = view.group === 'none' ? [{ label: null, jobs: shown }] : groupJobs(shown, [], groupLabel)
|
|
256
|
+
.sort((x, y) => x.label.localeCompare(y.label) * (view.groupDir === 'desc' ? -1 : 1));
|
|
257
|
+
const head = ({ label, jobs }) => groupHead(h, {
|
|
258
|
+
label: `${label}, ${jobs.length} ${jobs.length === 1 ? 'code' : 'codes'}`,
|
|
259
|
+
badge: h('span', { class: 'fx-strong' }, label, ' ', h('span', { class: 'fx-tabs__count' }, String(jobs.length))),
|
|
260
|
+
open: !folded.has(label), onFold: () => { if (folded.has(label)) folded.delete(label); else folded.add(label); draw(); },
|
|
261
|
+
span: columns.length + 1,
|
|
262
|
+
});
|
|
263
|
+
host.replaceChildren(heads.table({
|
|
264
|
+
className: 'fx-table--rows fx-price__table', columns,
|
|
265
|
+
rows: groups.map((g) => h('tbody', {}, g.label == null ? null : head(g), g.label != null && folded.has(g.label) ? null : g.jobs.map(itemRow))),
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
200
268
|
// Opened from Reconcile's "Edit code": that code's dialog, once.
|
|
201
269
|
if (opened) {
|
|
202
270
|
const item = data.items.find((i) => i.id === opened);
|
|
@@ -205,6 +273,8 @@ export async function renderPriceList(main, ui) {
|
|
|
205
273
|
if (item) edit(item);
|
|
206
274
|
}
|
|
207
275
|
}
|
|
276
|
+
// Folded groups, for this visit.
|
|
277
|
+
const folded = new Set();
|
|
208
278
|
|
|
209
279
|
async function load() {
|
|
210
280
|
try {
|
|
@@ -216,6 +286,5 @@ export async function renderPriceList(main, ui) {
|
|
|
216
286
|
draw();
|
|
217
287
|
}
|
|
218
288
|
|
|
219
|
-
addButton.addEventListener('click', () => edit(null));
|
|
220
289
|
await load();
|
|
221
290
|
}
|
package/server/ui/routes.js
CHANGED
|
@@ -12,6 +12,7 @@ import { themeStylesheet } from '../theme/themes.js';
|
|
|
12
12
|
// /assets/ui/<file> Flow's components (flow.css) and page scripts
|
|
13
13
|
// /assets/fonts/<pkg>/… the families' faces (server/theme/font-families.js)
|
|
14
14
|
// /assets/icons/<name>.svg one Lucide icon (lucide-static, ISC), for any page
|
|
15
|
+
// /assets/icons/tags.json each Lucide icon's words, for the icon picker
|
|
15
16
|
// Only the files named here are served. They change only with a release, so
|
|
16
17
|
// browsers may keep them for an hour.
|
|
17
18
|
|
|
@@ -21,7 +22,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
21
22
|
const logoDir = join(here, 'logos');
|
|
22
23
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
23
24
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
24
|
-
export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
25
|
+
export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
25
26
|
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
26
27
|
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
27
28
|
const MAX_AGE = '1h';
|
|
@@ -58,6 +59,8 @@ export function createUiAssetsRouter({ rateLimitPerMinute = 600 } = {}) {
|
|
|
58
59
|
if (!ICON_FILE.test(req.params.file)) return next();
|
|
59
60
|
sendNamed(res, next, iconDir, req.params.file);
|
|
60
61
|
});
|
|
62
|
+
// Each icon's words, for the icon picker's search (kit.js iconPicker).
|
|
63
|
+
router.get('/assets/icons/tags.json', (_req, res, next) => sendNamed(res, next, dirname(iconDir), 'tags.json'));
|
|
61
64
|
|
|
62
65
|
for (const name of FONT_PACKAGES) {
|
|
63
66
|
const dir = dirname(require.resolve(`@fontsource-variable/${name}/LICENSE`));
|
package/server/ui/saved-views.js
CHANGED
|
@@ -116,6 +116,8 @@ export function savedViews(ui, { page, current, apply }) {
|
|
|
116
116
|
}, isDefault ? lucide(h, 'house', 'Your default') : null, h('span', { class: 'fx-views__name' }, v.name), on && changed ? h('span', { class: 'fx-views__dot', 'aria-label': 'Changed', title: 'Changed' }) : null),
|
|
117
117
|
more, menu);
|
|
118
118
|
});
|
|
119
|
+
// A tab list needs tabs: with no saved view yet it is a plain group.
|
|
120
|
+
element.setAttribute('role', tabs.length ? 'tablist' : 'group');
|
|
119
121
|
element.replaceChildren(...[
|
|
120
122
|
...tabs,
|
|
121
123
|
saved.list.length < MAX_SAVED_VIEWS ? h('button', { type: 'button', class: 'fx-views__add', title: 'Save as a view', onclick: addView }, lucide(h, 'plus'), 'View') : null,
|