@arcforgelabs/flow 2026.10.51 → 2026.10.53

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.
Files changed (42) hide show
  1. package/npm-shrinkwrap.json +2 -2
  2. package/package.json +1 -1
  3. package/server/api/captures.js +26 -4
  4. package/server/api/invoicing.js +1 -1
  5. package/server/app.js +2 -0
  6. package/server/auth/people.js +3 -3
  7. package/server/core/ai.js +2 -1
  8. package/server/core/capture-match.js +158 -0
  9. package/server/core/captures.js +141 -7
  10. package/server/core/invoice-rules.js +16 -5
  11. package/server/core/reader.js +1 -10
  12. package/server/core/sending.js +7 -3
  13. package/server/db/migrations/0024_rule_icon.sql +5 -0
  14. package/server/db/migrations/meta/0024_snapshot.json +4703 -0
  15. package/server/db/migrations/meta/_journal.json +8 -1
  16. package/server/db/schema.js +3 -0
  17. package/server/integrations/seazona/history/backfill.js +39 -9
  18. package/server/integrations/seazona/index.js +1 -12
  19. package/server/integrations/seazona/stage-moves.js +3 -13
  20. package/server/integrations/sentry/index.js +6 -6
  21. package/server/integrations/setup.js +1 -0
  22. package/server/integrations/xero/drafts.js +27 -13
  23. package/server/ui/app-page.js +1 -1
  24. package/server/ui/app.js +61 -20
  25. package/server/ui/capture-fields.js +17 -0
  26. package/server/ui/captures.js +289 -0
  27. package/server/ui/columns.js +68 -3
  28. package/server/ui/custom-fields.js +3 -9
  29. package/server/ui/flow.css +94 -18
  30. package/server/ui/invoice-rules.js +10 -2
  31. package/server/ui/invoicing-settings.js +2 -8
  32. package/server/ui/invoicing.js +12 -8
  33. package/server/ui/job-view.js +6 -3
  34. package/server/ui/jobs.js +40 -117
  35. package/server/ui/kit.js +86 -1
  36. package/server/ui/list-views.js +47 -3
  37. package/server/ui/mobile.js +45 -5
  38. package/server/ui/nav-pins.js +4 -2
  39. package/server/ui/price-list.js +138 -69
  40. package/server/ui/routes.js +4 -1
  41. package/server/ui/saved-views.js +2 -0
  42. 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
@@ -1,10 +1,11 @@
1
- // A person's saved views of a list page (Jobs, Invoices, Reconcile), as
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
  }
@@ -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
  },
@@ -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, Invoices,
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',
@@ -1,27 +1,34 @@
1
- // Settings, Price list (/price-list; server/core/price-list.js,
2
- // docs/PRICE_LIST.md). Flow's own codes and prices, which work with no
3
- // integration connected: one table row per code, with its price and tax, and
4
- // the line price setting below. Edit opens a dialog with the code's fields, its
5
- // links and, when something differs, Mark resolved. While an integration with
6
- // its own price list is on, Reconcile (price-reconcile.js) says how many
7
- // records need a person and opens the work. Everyone can read it; an owner or
8
- // admin changes it. The caller passes in app.js's shared pieces as `ui`.
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 { normaliseListView, PRICE_COLUMNS } from '/assets/ui/job-view.js';
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: 'Work system amount' },
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, pageHeader, me, settingRow } = ui;
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-price' }, h('div', { class: 'fx-skeleton' }));
40
- let view = normaliseListView(me?.views?.['price-list'], PRICE_COLUMNS);
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: (patch) => {
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
- let data = null;
58
- let query = '';
59
- let tab = 'active';
60
- let opened = new URLSearchParams(location.search).get('code');
61
- const systemName = (id) => data.systems.find((s) => s.id === id)?.name ?? id;
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
- async function linePriceRow() {
152
- const select = h('select', { class: 'fx-select', 'aria-label': 'Line price from', disabled }, LINE_PRICES.map((p) => h('option', { value: p.value }, p.label)));
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
- select.value = automations.find((a) => a.key === 'map_lines')?.config.linePrice ?? 'work';
156
- } catch {
157
- select.value = 'work';
158
- }
159
- let saved = select.value;
160
- select.addEventListener('change', async () => {
161
- select.disabled = true;
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
- return row('Line price from', 'Where an invoice line takes its price.', select);
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 shown = (tab === 'archived' ? archived : live).filter((i) => !words || `${i.code} ${i.name}`.toLowerCase().includes(words));
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.replaceChildren('Reconcile', todo ? h('span', { class: 'fx-tabs__count', 'data-tone': 'warn' }, String(todo)) : null);
177
- reconcileButton.title = todo ? `${todo} records need a person` : 'Every record has a code, and every price agrees';
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
- const columns = view.columns.map((id) => ({ id, label: PRICE_COLUMNS[id], ...COLUMN_DEFS[id] }));
185
- const itemRow = (item) => h('tr', { onclick: () => edit(item), tabindex: '0', onkeydown: (event) => { if (event.key === 'Enter') edit(item); } },
186
- rowCells(h, columns, item),
187
- h('td', { class: 'fx-c-end' }));
188
- const table = shown.length
189
- ? h('div', { class: 'fx-panel' }, heads.table({ className: 'fx-table--rows fx-price__table', columns, rows: h('tbody', {}, shown.map(itemRow)) }))
190
- : h('p', { class: 'fx-empty' }, words ? 'No code matches.' : tab === 'archived' ? 'No archived codes.' : 'No codes yet. Add the first one.');
191
- const sections = [
192
- disabled ? notice('Only an owner or admin can change these.', 'info') : null,
193
- h('div', { class: 'fx-toolbar fx-price__head' }, tabs, search),
194
- table,
195
- ];
196
- const settings = h('section', { class: 'fx-set fx-price__settings' }, h('h2', { class: 'fx-set__title' }, 'Invoice lines'), h('div', { class: 'fx-skeleton' }));
197
- linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r));
198
- sections.push(settings);
199
- host.replaceChildren(...sections.filter(Boolean));
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
  }
@@ -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`));
@@ -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,