@arcforgelabs/flow 2026.10.51 → 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.
Files changed (41) 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 +111 -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 +283 -0
  27. package/server/ui/columns.js +68 -3
  28. package/server/ui/flow.css +92 -17
  29. package/server/ui/invoice-rules.js +10 -2
  30. package/server/ui/invoicing-settings.js +2 -8
  31. package/server/ui/invoicing.js +12 -8
  32. package/server/ui/job-view.js +6 -3
  33. package/server/ui/jobs.js +40 -117
  34. package/server/ui/kit.js +86 -1
  35. package/server/ui/list-views.js +47 -3
  36. package/server/ui/mobile.js +45 -5
  37. package/server/ui/nav-pins.js +4 -2
  38. package/server/ui/price-list.js +138 -69
  39. package/server/ui/routes.js +4 -1
  40. package/server/ui/saved-views.js +2 -0
  41. package/server/ui/view-bar.js +61 -2
@@ -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,
@@ -1,6 +1,6 @@
1
1
  // The view bar over a list: chips that open a small panel (Group, Sort,
2
2
  // Filter, Columns), as on ClickUp. Every list page builds its chips here, so
3
- // they look and work the same: Jobs (jobs.js) and Reconcile (reconcile.js).
3
+ // they look and work the same: Jobs, Invoices, Reconcile and the Price list.
4
4
  //
5
5
  // viewBar(h) answers the parts:
6
6
  // menu(icon, build, { rows, clearLabel }) a chip, its × and its panel.
@@ -9,6 +9,7 @@
9
9
  // option(text, checked, onclick, role, icon) a row in a panel, with a tick.
10
10
  // title, choose, bin, addButton, rowsFoot, clearAll the parts of a rows
11
11
  // panel: a title, rows of selects with a bin, a foot with Add and Clear.
12
+ // groupMenu(config), sortMenu(config) the Group and Sort chips (below).
12
13
  // filterMenu(config) the Filter chip, as ClickUp's: groups of rows of
13
14
  // field, operator and value, And / Or in a group and between groups,
14
15
  // "Add nested filter", "Add filter" and "Clear all" (job-view.js has
@@ -161,6 +162,64 @@ export function viewBar(h) {
161
162
  pop.style.left = `${Math.round(left)}px`;
162
163
  }
163
164
 
165
+ // ── Group by ──
166
+ // One row: what to group by, the group order, and a bin that takes the
167
+ // groups away when `clearable()` (Jobs' board always has lanes). The page
168
+ // names the chip with set(), as only it knows its group's name.
169
+ // options() [{ value, label } | { group, options }]
170
+ // view() { group, groupDir }; group 'none' is no groups
171
+ function groupMenu({ options, view, change, clearable = () => true }) {
172
+ return menu(ICONS.group, (redraw) => {
173
+ const { group, groupDir } = view();
174
+ const none = group === 'none';
175
+ return [
176
+ title('Group by'),
177
+ h('div', { class: 'fx-rows' }, h('div', { class: 'fx-rows__row fx-rows__row--group' },
178
+ choose('Group by', none ? '' : group, [...(none ? [{ value: '', label: 'Choose…', disabled: true }] : []), ...options()], (value) => { change({ group: value }); redraw(); }),
179
+ choose('Group order', groupDir, [{ value: 'asc', label: 'Ascending' }, { value: 'desc', label: 'Descending' }], (value) => { change({ groupDir: value }); redraw(); }, { disabled: none }),
180
+ clearable() && !none ? bin('Remove the grouping', () => { change({ group: 'none' }); redraw(); }) : h('span', { class: 'fx-rows__gap' }))),
181
+ ];
182
+ }, { rows: true, clearLabel: 'Remove the grouping' });
183
+ }
184
+
185
+ // ── Sort ──
186
+ // Rows of sorts, each applied in turn; a list's column heads sort too.
187
+ // options() [{ value, label } | { group, options }]
188
+ // view() { sorts: [{ by, dir }] }
189
+ // dirWords(by) the two directions' words, as ['A to Z', 'Z to A']
190
+ // nameOf(by) the chip's name for a sort
191
+ function sortMenu({ options, view, change, dirWords, nameOf, empty = 'No sort.' }) {
192
+ const setSorts = (sorts) => change({ sorts });
193
+ const control = menu(ICONS.sort, (redraw) => {
194
+ const { sorts } = view();
195
+ const unused = options().flatMap((o) => o.options ?? [o]).filter((o) => !sorts.some((s) => s.by === o.value));
196
+ return [
197
+ title('Sort'),
198
+ sorts.length ? h('div', { class: 'fx-rows' }, sorts.map((s, i) => {
199
+ const [up, down] = dirWords(s.by);
200
+ const others = sorts.filter((_, n) => n !== i).map((x) => x.by);
201
+ const shown = options().map((o) => (o.group ? { ...o, options: o.options.filter((x) => !others.includes(x.value)) } : o)).filter((o) => o.group || !others.includes(o.value));
202
+ return h('div', { class: 'fx-rows__row fx-rows__row--sort' },
203
+ h('span', { class: 'fx-rows__lead' }, i ? 'then' : 'Sort by'),
204
+ choose('Sort by', s.by, shown, (by) => { setSorts(sorts.map((x, n) => (n === i ? { by, dir: x.dir } : x))); redraw(); }),
205
+ choose('Order', s.dir, [{ value: 'asc', label: up }, { value: 'desc', label: down }], (dir) => { setSorts(sorts.map((x, n) => (n === i ? { ...x, dir } : x))); redraw(); }),
206
+ bin('Remove this sort', () => { setSorts(sorts.filter((_, n) => n !== i)); redraw(); }));
207
+ })) : h('p', { class: 'fx-rows__empty' }, empty),
208
+ rowsFoot(
209
+ sorts.length < 5 && unused.length ? addButton('Add sort', () => { setSorts([...sorts, { by: unused[0].value, dir: 'asc' }]); redraw(); }) : h('span'),
210
+ sorts.length ? clearAll(() => { setSorts([]); redraw(); }) : null),
211
+ ];
212
+ }, { rows: true, clearLabel: 'Clear the sort' });
213
+ // The chip names the sort that is on.
214
+ const draw = () => {
215
+ const { sorts } = view();
216
+ const [first] = sorts;
217
+ control.set(sorts.length > 1 ? `Sort · ${sorts.length}` : first ? `Sort: ${nameOf(first.by)} ${first.dir === 'desc' ? '↓' : '↑'}` : 'Sort', sorts.length > 0,
218
+ sorts.length ? () => setSorts([]) : null);
219
+ };
220
+ return { ...control, draw };
221
+ }
222
+
164
223
  // ── Filters ──
165
224
  // As ClickUp's: groups of rows of field, operator and value, with a bin on
166
225
  // each row. A group's second row has And / Or for the rows in it; the
@@ -329,7 +388,7 @@ export function viewBar(h) {
329
388
  return { ...control, draw };
330
389
  }
331
390
 
332
- return { menu, option, title, choose, bin, addButton, rowsFoot, clearAll, filterMenu };
391
+ return { menu, option, title, choose, bin, addButton, rowsFoot, clearAll, groupMenu, sortMenu, filterMenu };
333
392
  }
334
393
 
335
394
  // A list's own search: a round search icon in the view bar that opens into a