@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
@@ -10,7 +10,7 @@
10
10
  // or admin changes them. The full rules are in docs/INVOICING_RULES.md.
11
11
  // app.js passes in its shared pieces as `ui`.
12
12
 
13
- import { rulesSection } from '/assets/ui/invoice-rules.js';
13
+ import { ruleMark, rulesSection } from '/assets/ui/invoice-rules.js';
14
14
  import { lineMappingSection } from '/assets/ui/line-mappings.js';
15
15
 
16
16
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/INVOICING_RULES.md';
@@ -27,12 +27,6 @@ const PARTS = [
27
27
  { key: 'price-codes', title: 'Price codes', mount: (ui) => lineMappingSection(ui) },
28
28
  ];
29
29
 
30
- // What a rule does, as its card's mark: Pending, Waits, Never drafted.
31
- const KIND = {
32
- pending: { mark: 'flag' },
33
- wait: { mark: 'clock' },
34
- skip: { mark: 'ban' },
35
- };
36
30
  const SHOWN = 6;
37
31
 
38
32
  export function renderInvoicingSettings(main, ui, id) {
@@ -60,7 +54,7 @@ export function renderInvoicingSettings(main, ui, id) {
60
54
  return;
61
55
  }
62
56
  const ruleCard = (rule) => tileCard({
63
- row: true, href: page('invoices'), mark: (KIND[rule.action.kind] ?? { mark: 'list-checks' }).mark, title: rule.name,
57
+ row: true, href: page('invoices'), mark: ruleMark(rule), title: rule.name,
64
58
  meta: rule.enabled ? 'On' : 'Off',
65
59
  });
66
60
  const waiting = labels.filter((l) => l.unmapped > 0).length;
@@ -641,16 +641,20 @@ export async function renderInvoiceList(main, ui) {
641
641
  };
642
642
  const href = (invoice) => `/invoices/${invoice.id}`;
643
643
  const ALL = {
644
- number: numbered ? { kind: 'number', cell: (i) => link(href(i), i.number ?? 'Draft') } : null,
645
- client: { kind: 'client', phone: 'title', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
644
+ // The client is the row's link: on a phone it is the title, with a 44px hit area.
645
+ number: numbered ? { kind: 'number', phone: 'meta', className: 'fx-strong', cell: (i) => i.number ?? 'Draft' } : null,
646
+ client: { kind: 'client', phone: 'title', cell: (i) => link(href(i), i.contactName ?? 'No client') },
647
+ // A phone row: the client (and number) and total, then the problem,
648
+ // the reference or the status, and the date. A tab without numbers
649
+ // holds one status: its tab says it.
646
650
  reference: showProblem
647
- ? { label: 'Problem', kind: 'text', cell: problemCell }
648
- : { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
649
- date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.issueDate ?? i.createdAt?.slice(0, 10)) },
651
+ ? { label: 'Problem', kind: 'text', phone: 'meta', cell: problemCell }
652
+ : { kind: 'text', className: 'fx-dim', phone: numbered ? 'hide' : 'meta', cell: (i) => i.reference ?? '—' },
653
+ date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', phone: 'meta', cell: (i) => dateCell(h, i.issueDate ?? i.createdAt?.slice(0, 10)) },
650
654
  due: { kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.dueDate) },
651
655
  total: { kind: 'total', num: true, className: 'fx-strong', cell: (i) => formatCents(i.totalCents) },
652
- status: { kind: 'status', className: 'fx-status-cell', cell: statusCell },
653
- linked: linked ? { kind: 'link', cell: linkCell } : null,
656
+ status: { kind: 'status', className: 'fx-status-cell', ...(numbered ? {} : { phone: 'hide' }), cell: statusCell },
657
+ linked: linked ? { kind: 'link', phone: 'hide', cell: linkCell } : null,
654
658
  // The invoice's job (from the work system): its case, practitioner and
655
659
  // the day it shipped.
656
660
  case: { kind: 'case', className: 'fx-dim', cell: (i) => (i.job ? caseNumber(i.job) ?? '—' : '—') },
@@ -1449,7 +1453,7 @@ function issuedView(invoice, ui, context, redraw) {
1449
1453
  h('div', { class: 'fx-invoice__main' },
1450
1454
  h('section', { class: 'fx-card fx-doc' },
1451
1455
  h('dl', { class: 'fx-doc__meta' },
1452
- h('div', {}, h('dt', { class: 'fx-label' }, 'Bill to'), h('dd', {}, invoice.contact?.name ?? '—', invoice.contact?.email ? h('span', { class: 'fx-dim' }, invoice.contact.email) : null)),
1456
+ h('div', {}, h('dt', { class: 'fx-label' }, 'Bill to'), h('dd', {}, invoice.contact?.name ?? '—', invoice.contact?.email ? h('span', { class: 'fx-dim', title: invoice.contact.email }, invoice.contact.email) : null)),
1453
1457
  h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
1454
1458
  h('div', {}, h('dt', { class: 'fx-label' }, 'Date'), h('dd', {}, showDate(invoice.issueDate))),
1455
1459
  h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
@@ -273,7 +273,10 @@ function inDateValue(day, want, today, late) {
273
273
  // person looking (for "Me" in a person filter); `today` is yyyy-mm-dd.
274
274
  export function matchesFilter(filter, value, { today, me = null, late = null, kind = null } = {}) {
275
275
  const want = filter.value;
276
- const empty = value == null || value === '' || value === false;
276
+ // A field can hold many choices (the Price list's Linked): Is keeps a row
277
+ // with any of the wanted ones, Is not a row with none.
278
+ const empty = value == null || value === '' || value === false || (Array.isArray(value) && !value.length);
279
+ const holds = (w) => (Array.isArray(value) ? value.includes(w) : value === w);
277
280
  const day = typeof value === 'string' && ISO_DAY.test(value.slice(0, 10)) ? value.slice(0, 10) : null;
278
281
  if (kind === 'date' && ['is', 'not'].includes(filter.op)) {
279
282
  const inside = inDateValue(day, want, today, late);
@@ -284,8 +287,8 @@ export function matchesFilter(filter, value, { today, me = null, late = null, ki
284
287
  case 'unset': return empty;
285
288
  case 'yes': return value === true;
286
289
  case 'no': return value !== true;
287
- case 'is': return Array.isArray(want) ? want.map((w) => (w === 'me' ? me : w)).includes(value) : !empty && text(value) === text(want).trim();
288
- case 'not': return !want.map((w) => (w === 'me' ? me : w)).includes(value);
290
+ case 'is': return Array.isArray(want) ? want.map((w) => (w === 'me' ? me : w)).some(holds) : !empty && text(value) === text(want).trim();
291
+ case 'not': return !want.map((w) => (w === 'me' ? me : w)).some(holds);
289
292
  case 'has': return text(value).includes(text(want).trim());
290
293
  case 'hasnt': return !text(value).includes(text(want).trim());
291
294
  case 'eq': return typeof value === 'number' && value === want;
package/server/ui/jobs.js CHANGED
@@ -13,7 +13,8 @@
13
13
  // long press opens Move to. With a keyboard, the job's panel has the same moves.
14
14
 
15
15
  import { bulkBar } from '/assets/ui/bulk-bar.js';
16
- import { columnTable, dateCell, fieldColumn, onRightButton, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
16
+ import { captureButton } from '/assets/ui/captures.js';
17
+ import { columnTable, dateCell, fieldColumn, groupHead, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
17
18
  import { lucide } from '/assets/ui/custom-fields.js';
18
19
  import { formatCents } from '/assets/ui/invoicing.js';
19
20
  import { jobChangeReview } from '/assets/ui/job-change.js';
@@ -92,7 +93,6 @@ function saveSide(tab) {
92
93
 
93
94
  const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6v3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r=".9" fill="currentColor"/></svg>';
94
95
  const PERSON_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="5.5" r="3" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 14c.6-2.8 2.8-4.3 5.5-4.3s4.9 1.5 5.5 4.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
95
- const CHEVRON_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3.5 4.5 4.5L6 12.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
96
96
  const CALENDAR_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
97
97
 
98
98
  export async function renderJobs(main, ui) {
@@ -126,7 +126,9 @@ export async function renderJobs(main, ui) {
126
126
  // In the list, ticked rows get the same floating bar as Invoices.
127
127
  const bulk = bulkBar(ui, { label: 'Ticked jobs' });
128
128
  const frame = listFrame(main, host);
129
- main.append(...listHead('Jobs', { views: views.element, controls, end: [find.element, toggle] }).nodes, frame.element, bulk.element);
129
+ // A form photo from the Jobs page; Capture lists it with its result.
130
+ const shoot = captureButton(ui, { icon: true, added: () => ui.toast('Sent to Capture.', 'success') });
131
+ main.append(...listHead('Jobs', { views: views.element, controls, end: [find.element, toggle, ...shoot.nodes] }).nodes, frame.element, bulk.element);
130
132
 
131
133
  const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
132
134
  type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
@@ -170,7 +172,7 @@ export async function renderJobs(main, ui) {
170
172
  }
171
173
 
172
174
  // The chips (view-bar.js), shared with every list page.
173
- const { menu, option, title, choose, bin, addButton, rowsFoot, clearAll, filterMenu: buildFilterMenu } = viewBar(h);
175
+ const { menu, option, groupMenu: buildGroupMenu, sortMenu: buildSortMenu, filterMenu: buildFilterMenu } = viewBar(h);
174
176
  const DATE_WORDS = ['Oldest first', 'Newest first'];
175
177
  const NUMBER_WORDS = ['Low to high', 'High to low'];
176
178
  const DIR_WORDS = { total: NUMBER_WORDS, due: DATE_WORDS, ordered: DATE_WORDS, shipped: DATE_WORDS };
@@ -178,54 +180,31 @@ export async function renderJobs(main, ui) {
178
180
  const dirWords = (by) => DIR_WORDS[by] ?? TYPE_WORDS[fieldById(fieldIdOf(by))?.type] ?? ['A to Z', 'Z to A'];
179
181
  const fieldById = (id) => state.fields.find((f) => f.id === id) ?? null;
180
182
 
181
- // ── Group by ──
182
- // One row: what to group by, the group order, and a bin that takes the
183
- // groups away (the list only: the board always has lanes).
183
+ // ── Group by, Sort ── (view-bar.js). The board always has lanes, so only
184
+ // the list can take the groups away. In the list, a column's head sorts
185
+ // too.
184
186
  const groupOptions = () => [
185
187
  ...Object.entries(GROUPS).filter(([id]) => id !== 'none').map(([value, label]) => ({ value, label })),
186
188
  { group: 'Custom fields', options: groupable().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
187
189
  ];
188
- const groupMenu = menu(ICONS.group, (redraw) => {
189
- const list = state.view.layout === 'list';
190
- const group = state.view.group === 'none' && !list ? 'status' : state.view.group;
191
- const none = group === 'none';
192
- return [
193
- title('Group by'),
194
- h('div', { class: 'fx-rows' }, h('div', { class: 'fx-rows__row fx-rows__row--group' },
195
- choose('Group by', none ? '' : group, [...(none ? [{ value: '', label: 'Choose…', disabled: true }] : []), ...groupOptions()], (value) => { change({ group: value }); redraw(); }),
196
- choose('Group order', state.view.groupDir, [{ value: 'asc', label: 'Ascending' }, { value: 'desc', label: 'Descending' }], (value) => { change({ groupDir: value }); redraw(); }, { disabled: none }),
197
- list && !none ? bin('Remove the grouping', () => { change({ group: 'none' }); redraw(); }) : h('span', { class: 'fx-rows__gap' }))),
198
- ];
199
- }, { rows: true, clearLabel: 'Remove the grouping' });
200
-
201
- // ── Sort ──
202
- // Rows of sorts, each applied in turn. In the list, a column's head sorts
203
- // too.
204
- const sortOptions = () => [
205
- ...Object.entries(SORTS).map(([value, label]) => ({ value, label })),
206
- { group: 'Custom fields', options: state.fields.map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
207
- ];
208
- const setSorts = (sorts) => change({ sorts });
209
- const sortMenu = menu(ICONS.sort, (redraw) => {
210
- const { sorts } = state.view;
211
- const unused = sortOptions().flatMap((o) => o.options ?? [o]).filter((o) => !sorts.some((s) => s.by === o.value));
212
- return [
213
- title('Sort'),
214
- sorts.length ? h('div', { class: 'fx-rows' }, sorts.map((s, i) => {
215
- const [up, down] = dirWords(s.by);
216
- const others = sorts.filter((_, n) => n !== i).map((x) => x.by);
217
- const options = sortOptions().map((o) => (o.group ? { ...o, options: o.options.filter((x) => !others.includes(x.value)) } : o)).filter((o) => o.group || !others.includes(o.value));
218
- return h('div', { class: 'fx-rows__row fx-rows__row--sort' },
219
- h('span', { class: 'fx-rows__lead' }, i ? 'then' : 'Sort by'),
220
- choose('Sort by', s.by, options, (by) => { setSorts(sorts.map((x, n) => (n === i ? { by, dir: x.dir } : x))); redraw(); }),
221
- choose('Order', s.dir, [{ value: 'asc', label: up }, { value: 'desc', label: down }], (dir) => { setSorts(sorts.map((x, n) => (n === i ? { ...x, dir } : x))); redraw(); }),
222
- bin('Remove this sort', () => { setSorts(sorts.filter((_, n) => n !== i)); redraw(); }));
223
- })) : h('p', { class: 'fx-rows__empty' }, 'No sort. Jobs show in the lab system’s order.'),
224
- rowsFoot(
225
- sorts.length < 5 && unused.length ? addButton('Add sort', () => { setSorts([...sorts, { by: unused[0].value, dir: 'asc' }]); redraw(); }) : h('span'),
226
- sorts.length ? clearAll(() => { setSorts([]); redraw(); }) : null),
227
- ];
228
- }, { rows: true, clearLabel: 'Clear the sort' });
190
+ const isList = () => state.view.layout === 'list';
191
+ const groupMenu = buildGroupMenu({
192
+ options: groupOptions,
193
+ view: () => ({ group: state.view.group === 'none' && !isList() ? 'status' : state.view.group, groupDir: state.view.groupDir }),
194
+ change,
195
+ clearable: isList,
196
+ });
197
+ const sortMenu = buildSortMenu({
198
+ options: () => [
199
+ ...Object.entries(SORTS).map(([value, label]) => ({ value, label })),
200
+ { group: 'Custom fields', options: state.fields.map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
201
+ ],
202
+ view: () => state.view,
203
+ change,
204
+ dirWords,
205
+ nameOf: (by) => SORTS[by] ?? fieldById(fieldIdOf(by))?.name ?? 'Custom field',
206
+ empty: 'No sort. Jobs show in the lab system’s order.',
207
+ });
229
208
 
230
209
  // ── Filters ──
231
210
  // The shared filter panel (view-bar.js), as ClickUp's: groups of rows of
@@ -301,8 +280,6 @@ export async function renderJobs(main, ui) {
301
280
  });
302
281
  controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node, ...columnsMenu.node);
303
282
 
304
- // A sort's name: a built-in key or a custom field.
305
- const sortName = (by) => SORTS[by] ?? fieldById(fieldIdOf(by))?.name ?? 'Custom field';
306
283
  function drawControls() {
307
284
  const { group, sorts, layout } = state.view;
308
285
  const list = layout === 'list';
@@ -314,9 +291,7 @@ export async function renderJobs(main, ui) {
314
291
  groupMenu.set(label, shown !== DEFAULT_VIEW.group, grouped ? () => change({ group: list ? 'none' : 'status' }) : null);
315
292
  // In the list, the column heads sort; the Sort chip shows there only
316
293
  // while a sort is on, so it can be seen and cleared.
317
- const [first] = sorts;
318
- sortMenu.set(sorts.length > 1 ? `Sort · ${sorts.length}` : first ? `Sort: ${sortName(first.by)} ${first.dir === 'desc' ? '↓' : '↑'}` : 'Sort', sorts.length > 0,
319
- sorts.length ? () => change({ sorts: [] }) : null);
294
+ sortMenu.draw();
320
295
  sortMenu.wrap.hidden = list && !sorts.length;
321
296
  filterMenu.draw();
322
297
  columnsMenu.set('Columns', false);
@@ -809,44 +784,6 @@ export async function renderJobs(main, ui) {
809
784
  if (field.type === 'checkbox') return value === true ? 1 : 0;
810
785
  return typeof value === 'string' ? value.toLowerCase() : value;
811
786
  }
812
- // A column head's sort button, as ClickUp's: it shows on hover, or
813
- // always while it sorts. A click sorts one way, again the other way, and
814
- // a third time takes the sort off. A right-click on a sorting head opens
815
- // a small menu: change the direction, or clear the sort.
816
- function sortButton(columnId, label) {
817
- const by = sortOf(columnId);
818
- if (!by) return null;
819
- const { sorts } = state.view;
820
- const at = sorts.findIndex((s) => s.by === by);
821
- const on = at >= 0 ? sorts[at] : null;
822
- const [up, down] = dirWords(by);
823
- const next = () => {
824
- if (!on) change({ sorts: [{ by, dir: 'asc' }] });
825
- else if (on.dir === 'asc') change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: 'desc' } : s)) });
826
- else change({ sorts: sorts.filter((_, n) => n !== at) });
827
- };
828
- const words = on ? `Sorted ${on.dir === 'asc' ? up : down}. Click to ${on.dir === 'asc' ? `sort ${down}` : 'clear the sort'}` : `Sort by ${label}`;
829
- return h('button', {
830
- type: 'button', class: `fx-th-sort${on ? ' fx-th-sort--on' : ''}`, 'aria-label': words, title: words,
831
- onclick: (event) => { event.stopPropagation(); next(); },
832
- oncontextmenu: (event) => {
833
- if (!on) return;
834
- event.preventDefault();
835
- event.stopPropagation();
836
- onRightButton(event, () => sortMenuAt(event, by, label, on, at));
837
- },
838
- }, lucide(h, on ? (on.dir === 'asc' ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'),
839
- on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
840
- }
841
- function sortMenuAt(event, by, label, on, at) {
842
- const { sorts } = state.view;
843
- const flip = () => change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)) });
844
- heads.menu.show([
845
- h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { heads.menu.close(); flip(); } },
846
- h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${label}`), h('span', { class: 'fx-pop__end' }, svg(ICONS.tick))),
847
- heads.menu.item('ban', 'Clear sort', () => change({ sorts: sorts.filter((_, n) => n !== at) })),
848
- ], event);
849
- }
850
787
  // The list's heads (columns.js): drag an edge to resize, drag a head to
851
788
  // move it; a click or right-click opens its menu: sort by it, move it,
852
789
  // edit its custom field, hide it. Client always shows, first. + lists the
@@ -862,19 +799,9 @@ export async function renderJobs(main, ui) {
862
799
  ...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
863
800
  ];
864
801
  },
865
- extra: (column, item) => {
866
- const parts = [];
867
- const sort = sortOf(column.id);
868
- if (sort) {
869
- const [up, down] = dirWords(sort);
870
- parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sorts: [{ by: sort, dir: 'asc' }] })),
871
- item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sorts: [{ by: sort, dir: 'desc' }] })), h('div', { class: 'fx-pop__sep' }));
872
- }
873
- if (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)) {
874
- parts.push(item('settings', 'Edit custom field', () => ui.go('/custom-fields')));
875
- }
876
- return parts;
877
- },
802
+ sorting: { sortOf, sorts: () => state.view.sorts, change, dirWords },
803
+ extra: (column, item) => (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)
804
+ ? [item('settings', 'Edit custom field', () => ui.go('/custom-fields'))] : []),
878
805
  });
879
806
 
880
807
  // The list's columns, in the order the person chose them. One column holds
@@ -896,14 +823,14 @@ export async function renderJobs(main, ui) {
896
823
  const allowed = new Set(builtInColumns());
897
824
  // Client is always the first column: it has no drag, Move or Hide.
898
825
  const client = {
899
- id: 'client', label: 'Client', kind: 'client', fixed: true, sort: () => sortButton('client', 'Client'),
826
+ id: 'client', label: 'Client', kind: 'client', fixed: true,
900
827
  cell: (job) => h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job)),
901
828
  };
902
829
  return [client, ...state.view.columns.map((id) => {
903
830
  if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
904
831
  const field = customColumns().find((f) => fieldGroup(f.id) === id);
905
832
  return field ? fieldColumn(h, field, { id, valueOf: (job) => valueOf(job, field.id)?.value ?? null, people: state.people }) : null;
906
- }).filter(Boolean).map((c) => ({ ...c, sort: () => sortButton(c.id, c.label) }))];
833
+ }).filter(Boolean)];
907
834
  }
908
835
 
909
836
  // A person custom field's value as a badge (initials), the name on hover.
@@ -953,17 +880,13 @@ export async function renderJobs(main, ui) {
953
880
  saveFolded(state.folded);
954
881
  draw();
955
882
  };
956
- const head = ({ label, jobs, rank, color }) => {
957
- const open = !folded.has(label);
958
- const toggle = h('button', {
959
- type: 'button', class: 'fx-table__fold', 'aria-expanded': String(open), 'aria-label': `${label}, ${jobs.length} ${jobs.length === 1 ? 'job' : 'jobs'}. ${open ? 'Fold' : 'Open'} the group`,
960
- onclick: (event) => { event.stopPropagation(); fold(label); },
961
- }, svg(CHEVRON_ICON),
962
- color ? h('span', { class: 'fx-cf__opt fx-board__lane', 'data-color': color }, label, h('span', { class: 'fx-stage__count' }, String(jobs.length))) : stagePill(label, jobs.length, rank));
963
- return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: () => fold(label) },
964
- tickCell(setBox(jobs, `Tick every job in ${label}`)),
965
- h('td', { colspan: String(columns.length + 1) }, h('span', { class: 'fx-table__group-row' }, toggle, h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))))));
966
- };
883
+ const head = ({ label, jobs, rank, color }) => groupHead(h, {
884
+ label: `${label}, ${jobs.length} ${jobs.length === 1 ? 'job' : 'jobs'}`,
885
+ badge: color ? h('span', { class: 'fx-cf__opt fx-board__lane', 'data-color': color }, label, h('span', { class: 'fx-stage__count' }, String(jobs.length))) : stagePill(label, jobs.length, rank),
886
+ open: !folded.has(label), onFold: () => fold(label),
887
+ lead: tickCell(setBox(jobs, `Tick every job in ${label}`)), span: columns.length + 1,
888
+ extra: h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))),
889
+ });
967
890
  return h('div', { class: 'fx-panel' }, heads.table({
968
891
  className: 'fx-table--jobs',
969
892
  columns,
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
  },