@arcforgelabs/flow 2026.10.44 → 2026.10.46

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.
@@ -418,6 +418,24 @@ export function widthOf(column, widths) {
418
418
  return Number.isFinite(saved) ? Math.round(Math.min(MAX_WIDTH, Math.max(minWidth(column.kind), saved))) : startWidth(column.kind);
419
419
  }
420
420
 
421
+ // A list table's columns: the tick column at the start, the end column
422
+ // (+) at least this wide.
423
+ export const LEAD_WIDTH = 44;
424
+ export const TAIL_WIDTH = 40;
425
+
426
+ // The width a list table needs for its columns, with the end column at
427
+ // its least.
428
+ export function tableWidth(columns, widths, lead = 0) {
429
+ return columns.reduce((sum, c) => sum + widthOf(c, widths), 0) + lead * LEAD_WIDTH + TAIL_WIDTH;
430
+ }
431
+
432
+ // The end column's width: the room left after the other columns, so the
433
+ // head and rows reach the window's right edge (as HubSpot's), never under
434
+ // TAIL_WIDTH. A window not drawn yet (width 0) gets TAIL_WIDTH.
435
+ export function endWidth(need, room) {
436
+ return Math.max(TAIL_WIDTH, Math.floor(room) - (need - TAIL_WIDTH));
437
+ }
438
+
421
439
  // Saved widths as Flow keeps them: known-looking ids, whole pixels in range.
422
440
  // The minimum per kind applies when a width is drawn (widthOf), since the
423
441
  // server does not know a custom column's kind.
@@ -455,18 +473,29 @@ export function inOrder(columns, ids) {
455
473
  // shows the invoice's problem; Number shows on the tabs with numbers only.
456
474
  export const INVOICE_COLUMNS = Object.freeze({
457
475
  number: 'Number', client: 'Client', reference: 'Reference', date: 'Date', total: 'Total', status: 'Status', linked: 'Linked',
476
+ due: 'Due', case: 'Case', practitioner: 'Practitioner', shipped: 'Shipped',
477
+ });
478
+ // The Invoices columns a person sees before they choose: the others (the
479
+ // invoice's due date, its job's case, practitioner and shipped date, and
480
+ // the lab's custom fields) wait in the + menu.
481
+ export const INVOICE_SHOWN = Object.freeze(['number', 'client', 'reference', 'date', 'total', 'status', 'linked']);
482
+ // A person's Invoices columns: custom fields ('field:<id>') are columns too.
483
+ export const invoiceColumnsView = (input) => normaliseListView(input, INVOICE_COLUMNS, { shown: INVOICE_SHOWN, fields: true });
484
+ export const PRICE_COLUMNS = Object.freeze({
485
+ code: 'Code', name: 'Name', price: 'Price', tax: 'Tax',
458
486
  });
459
487
  export const RECONCILE_COLUMNS = Object.freeze({
460
488
  kind: 'Type', client: 'Client', detail: 'Detail', date: 'Date', total: 'Total', possible: 'Possible',
461
489
  });
462
490
 
463
- // A person's view of a list with set columns (Invoices, Reconcile): the
464
- // shown columns in order (every one when none is saved) and their widths.
465
- export function normaliseListView(input, columns) {
491
+ // A person's view of a list with set columns (Invoices, Reconcile, Price list): the
492
+ // shown columns in order (`shown`, else every one, when none is saved) and
493
+ // their widths. With `fields`, a custom field ('field:<id>') is a column too.
494
+ export function normaliseListView(input, columns, { shown = null, fields = false } = {}) {
466
495
  const value = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
467
496
  const ids = Object.keys(columns);
468
497
  return {
469
- columns: cleanColumnIds(value.columns, (id) => ids.includes(id)) ?? ids,
498
+ columns: cleanColumnIds(value.columns, (id) => ids.includes(id) || (fields && FIELD_GROUP.test(id))) ?? [...(shown ?? ids)],
470
499
  widths: cleanWidths(value.widths),
471
500
  };
472
501
  }
package/server/ui/jobs.js CHANGED
@@ -13,7 +13,7 @@
13
13
  // or 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, onRightButton, rowCells, viewSaver } from '/assets/ui/columns.js';
16
+ import { columnTable, dateCell, fieldColumn, onRightButton, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
17
17
  import { lucide } from '/assets/ui/custom-fields.js';
18
18
  import { formatCents } from '/assets/ui/invoicing.js';
19
19
  import { jobChangeReview } from '/assets/ui/job-change.js';
@@ -24,7 +24,7 @@ import {
24
24
  COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
25
25
  normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
26
26
  } from '/assets/ui/job-view.js';
27
- import { clipLabel, initialsOf } from '/assets/ui/person.js';
27
+ import { clipLabel } from '/assets/ui/person.js';
28
28
  import { savedViews } from '/assets/ui/saved-views.js';
29
29
  import { sidecar } from '/assets/ui/sidecar.js';
30
30
  import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
@@ -52,18 +52,6 @@ const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4
52
52
 
53
53
  const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
54
54
  const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
55
- // A list date, short: Today, Tomorrow or Yesterday, else day/month (and
56
- // year when it is not this year).
57
- const shortDate = (iso) => {
58
- if (!iso) return '—';
59
- const at = new Date(`${iso}T00:00:00`);
60
- const now = new Date();
61
- const days = Math.round((at - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
62
- if (days === 0) return 'Today';
63
- if (days === 1) return 'Tomorrow';
64
- if (days === -1) return 'Yesterday';
65
- return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
66
- };
67
55
  const clientOf = (job) => job.clientName ?? 'No client';
68
56
  // The one date a card shows: when the job is due, else when it was ordered.
69
57
  const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
@@ -136,7 +124,7 @@ export async function renderJobs(main, ui) {
136
124
  // In the list, ticked rows get the same floating bar as Invoices.
137
125
  const bulk = bulkBar(ui, { label: 'Ticked jobs' });
138
126
  const frame = listFrame(main, host);
139
- main.append(...listHead('Jobs', { views: views.element, start: controls, end: [find.element, toggle] }).nodes, frame.element, bulk.element);
127
+ main.append(...listHead('Jobs', { views: views.element, controls, end: [find.element, toggle] }).nodes, frame.element, bulk.element);
140
128
 
141
129
  const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
142
130
  type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
@@ -799,7 +787,7 @@ export async function renderJobs(main, ui) {
799
787
  // The list's columns, in the order the person chose them. One column holds
800
788
  // one field; each date has its own column.
801
789
  function listColumns() {
802
- const day = (iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
790
+ const day = (iso) => dateCell(h, iso);
803
791
  const late = (job) => (isLate(job) ? h('span', { class: 'fx-job__date--late', title: 'Past due' }, day(job.dueOn)) : day(job.dueOn));
804
792
  const BUILT_IN = {
805
793
  case: { kind: 'case', className: 'fx-dim', cell: (job) => caseNumber(job) ?? '—' },
@@ -821,31 +809,12 @@ export async function renderJobs(main, ui) {
821
809
  return [client, ...state.view.columns.map((id) => {
822
810
  if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
823
811
  const field = customColumns().find((f) => fieldGroup(f.id) === id);
824
- if (!field) return null;
825
- const cell = field.type === 'person' ? (job) => personBadge(job, field)
826
- : field.type === 'date' ? (job) => day(valueOf(job, field.id)?.value ?? null) : (job) => fieldText(job, field);
827
- return { id, label: field.name, kind: field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text', num: field.type === 'number', className: 'fx-dim', cell };
812
+ return field ? fieldColumn(h, field, { id, valueOf: (job) => valueOf(job, field.id)?.value ?? null, people: state.people }) : null;
828
813
  }).filter(Boolean).map((c) => ({ ...c, sort: () => sortButton(c.id, c.label) }))];
829
814
  }
830
815
 
831
816
  // A person custom field's value as a badge (initials), the name on hover.
832
- function personBadge(job, field) {
833
- const value = field ? valueOf(job, field.id)?.value ?? null : null;
834
- if (!value) return h('span', { class: 'fx-dim' }, '—');
835
- const name = state.people.find((p) => p.id === value)?.name ?? 'Former person';
836
- return h('span', { class: 'fx-avatar fx-avatar--sm', role: 'img', 'aria-label': name, title: name }, initialsOf(name));
837
- }
838
-
839
- // A job's custom field value as text.
840
- function fieldText(job, field) {
841
- const value = field ? valueOf(job, field.id)?.value ?? null : null;
842
- if (value == null || value === false) return '—';
843
- if (field.type === 'checkbox') return 'Yes';
844
- if (field.type === 'person') return state.people.find((p) => p.id === value)?.name ?? 'Former person';
845
- if (field.type === 'dropdown') return field.options?.find((o) => o.id === value)?.name ?? '—';
846
- if (field.type === 'date') return showDate(value);
847
- return String(value);
848
- }
817
+ const personBadge = (job, field) => personCell(h, field ? valueOf(job, field.id)?.value ?? null : null, state.people);
849
818
 
850
819
  // The list: one block per group, with the group's label over its rows.
851
820
  // A tick column at the left; a group's head has a tick for every job in
package/server/ui/kit.js CHANGED
@@ -63,22 +63,28 @@ export function pageHeader(title, { count = null, help = null, actions = [] } =
63
63
 
64
64
  // The top of a list page (Jobs, Invoices, Reconcile), laid out the same on
65
65
  // every list and in Board and List alike, so nothing moves when a person
66
- // switches: the title row, a row for saved view tabs (`views`: the page's
67
- // savedViews element, else an empty slot that stays hidden), then one toolbar with `start` at the left (tabs, Group,
68
- // Sort, Filter) and `end` at the right (search, Board/List), then the page's
69
- // `actions` (New invoice) last, as Claude's own lists do. `line` draws the
70
- // rule that tabs sit on; `sticky` keeps the toolbar at the top while the rows
71
- // scroll up under it, with a shadow once they do. Append `nodes` to the page.
72
- export function listHead(title, { count = null, help = null, actions = [], start = [], end = [], line = false, sticky = false, views: tabs = null } = {}) {
66
+ // switches: the title row, then one toolbar with `start` at the left (tabs,
67
+ // Group, Sort, Filter) and `end` at the right (search, Board/List), then the
68
+ // page's `actions` (New invoice) last, as Claude's own lists do. `controls`
69
+ // (the page's Group, Sort and Filter) sit with the saved view tabs at their
70
+ // right (`views`: the page's savedViews element, else an empty slot that
71
+ // stays hidden), at the end of `start`, or of `end` when `controlsAt` is
72
+ // 'end'. `line` draws the rule that tabs sit on; `sticky` keeps the toolbar
73
+ // at the top while the rows scroll up under it, with a shadow once they do.
74
+ // Append `nodes` to the page.
75
+ export function listHead(title, { count = null, help = null, actions = [], start = [], end = [], controls = null, controlsAt = 'start', line = false, sticky = false, views: tabs = null } = {}) {
73
76
  const views = tabs ?? h('div', { class: 'fx-views', role: 'group', 'aria-label': 'Views', hidden: true });
77
+ const tools = h('div', { class: 'fx-toolbar__tools' }, controls, views);
78
+ const left = controlsAt === 'end' ? [start].flat() : [start, tools].flat();
79
+ const right = controlsAt === 'end' ? [tools, end].flat() : [end].flat();
74
80
  const bar = h('div', { class: 'fx-listhead', 'data-sticky': sticky },
75
- h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, start, h('div', { class: 'fx-toolbar__end' }, end, actions)));
81
+ h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, left, h('div', { class: 'fx-toolbar__end' }, right, actions)));
76
82
  const edge = sticky ? h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' }) : null;
77
83
  if (edge && typeof IntersectionObserver === 'function') {
78
84
  const top = window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1;
79
85
  new IntersectionObserver(([entry]) => bar.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${top}px 0px 0px 0px` }).observe(edge);
80
86
  }
81
- return { nodes: [pageHeader(title, { count, help }), views, edge, bar].filter(Boolean), views, bar };
87
+ return { nodes: [pageHeader(title, { count, help }), edge, bar].filter(Boolean), views, bar };
82
88
  }
83
89
 
84
90
  // The window a list or board sits in under listHead, as HubSpot's: on a
@@ -208,9 +214,10 @@ let rowMenus = 0;
208
214
 
209
215
  // A row's ⋯ menu, as on the Custom fields page. The menu lines up with the
210
216
  // button's right edge; `align: 'start'` lines it up with its left edge.
211
- export function rowMenu(label, items, { align = 'end' } = {}) {
217
+ // `trigger` swaps the ⋯ icon for the given contents (the sidebar's More).
218
+ export function rowMenu(label, items, { trigger = null, className = 'fx-cfm__more', align = 'end' } = {}) {
212
219
  const id = `fx-row-menu-${++rowMenus}`;
213
- const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, lucide(h, 'ellipsis'));
220
+ const button = h('button', { class: className, type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, trigger ?? lucide(h, 'ellipsis'));
214
221
  const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
215
222
  panel.addEventListener('toggle', (event) => {
216
223
  button.setAttribute('aria-expanded', String(event.newState === 'open'));
@@ -3,10 +3,12 @@
3
3
  // Filter, Columns), and one can be the person's default, which opens when
4
4
  // they go to the page. No DOM: the server checks a saved list with the same
5
5
  // rules the page uses (server/auth/people.js).
6
- import { cleanFilterView, normaliseJobsView } from './job-view.js';
6
+ import { cleanFilterView, filterJobs, normaliseJobsView } from './job-view.js';
7
7
 
8
8
  // The Invoices filter's fields (invoicing.js). The tabs are the status, so
9
- // Status is not here.
9
+ // Status is not here. The page shows the chip (view-bar.js); the server keeps
10
+ // the rows (GET /api/invoicing/invoices), so a filter reaches every invoice
11
+ // of a tab.
10
12
  export const INVOICE_FILTER_FIELDS = {
11
13
  client: { label: 'Client', kind: 'choice', icon: 'building-2' },
12
14
  number: { label: 'Number', kind: 'text', icon: 'hash' },
@@ -16,6 +18,46 @@ export const INVOICE_FILTER_FIELDS = {
16
18
  problem: { label: 'Problem', kind: 'check', icon: 'triangle-alert' },
17
19
  };
18
20
 
21
+ export const invoiceFilterKind = (field) => INVOICE_FILTER_FIELDS[field]?.kind ?? null;
22
+
23
+ // An invoice's value for a filter field or a sort, as they compare it. A
24
+ // draft has no issue date: it uses the day it was made.
25
+ export function invoiceValue(invoice, field) {
26
+ switch (field) {
27
+ case 'client': return invoice.contactName ?? null;
28
+ case 'number': return invoice.number ?? null;
29
+ case 'reference': return invoice.reference ?? null;
30
+ case 'date': return invoice.issueDate ?? (invoice.createdAt ? new Date(invoice.createdAt).toISOString().slice(0, 10) : null);
31
+ case 'total': return invoice.totalCents == null ? null : invoice.totalCents / 100;
32
+ case 'problem': return (invoice.problems?.length ?? 0) > 0;
33
+ default: return null;
34
+ }
35
+ }
36
+
37
+ // What the Invoices list sorts by (a column head). No sort is newest first.
38
+ export const INVOICE_SORTS = Object.freeze(['number', 'client', 'reference', 'date', 'total']);
39
+
40
+ // One page of an invoice list: the Filter view keeps rows, the sort orders
41
+ // them (an empty value last, either way), then `offset` and `limit` cut the
42
+ // page. `total` and `totalCents` are of every kept row. The rows come in
43
+ // newest first; that order breaks ties.
44
+ export function invoicePage(invoices, { view, sort = null, dir = 'asc', offset = 0, limit = 100, today }) {
45
+ const kept = filterJobs(invoices, view, { valueOf: invoiceValue, kindOf: invoiceFilterKind, today });
46
+ const sign = dir === 'desc' ? -1 : 1;
47
+ const ordered = INVOICE_SORTS.includes(sort)
48
+ ? kept.map((invoice, i) => [invoice, invoiceValue(invoice, sort), i]).sort(([, x, i], [, y, j]) => {
49
+ if (x == null || y == null) return x == null && y == null ? i - j : (x == null) - (y == null);
50
+ const order = typeof x === 'number' ? x - y : String(x).localeCompare(String(y), 'en', { numeric: true });
51
+ return order ? order * sign : i - j;
52
+ }).map(([invoice]) => invoice)
53
+ : kept;
54
+ return {
55
+ rows: ordered.slice(offset, offset + limit),
56
+ total: kept.length,
57
+ totalCents: kept.reduce((sum, i) => sum + (i.totalCents ?? 0), 0),
58
+ };
59
+ }
60
+
19
61
  // The Reconcile filter's fields (reconcile.js). Reconcile lists jobs and
20
62
  // invoices, so it has both lists' fields; a job's own field (Practitioner,
21
63
  // a custom field) skips invoice rows, so it never hides them.
@@ -17,15 +17,46 @@ function loadScript({ src, integrity }) {
17
17
  });
18
18
  }
19
19
 
20
- // "Notify me": the form's email field (Sentry's own words) starts with the
21
- // signed-in person's email. A report sent with an address asks to hear back;
22
- // Flow tags it notify=yes, so the support desk sees it on the task. A person
23
- // who clears the field sends notify=no.
24
- // Sentry's form has no tick box of its own; this keeps to its stock options.
25
- export function tagNotify(event) {
20
+ // "Request a response": a tick box under the form's email field, off by
21
+ // default. The email field (Sentry's own words) starts with the signed-in
22
+ // person's email, but an address alone does not ask for a reply. A report
23
+ // sent with the box ticked and an address is tagged notify=yes, so the
24
+ // support desk replies (fixed, planned or won't fix). Every other report
25
+ // sends notify=no. Sentry's form has no tick box of its own (SDK 11.4.0),
26
+ // so Flow adds one to it (addResponseBox).
27
+ export function tagNotify(event, wantsResponse = false) {
26
28
  if (event?.type !== 'feedback') return;
27
29
  const email = String(event.contexts?.feedback?.contact_email ?? '').trim();
28
- event.tags = { ...event.tags, notify: email ? 'yes' : 'no' };
30
+ event.tags = { ...event.tags, notify: wantsResponse && email ? 'yes' : 'no' };
31
+ }
32
+
33
+ export const RESPONSE_LABEL = 'Request a response';
34
+
35
+ // The tick box goes under the email field inside the form's shadow root, so
36
+ // flow.css cannot reach it: its look comes from the form's own variables,
37
+ // which flow.css sets from the --t-* tokens (#sentry-feedback). Ticked, the
38
+ // email is required. No email field (or no form yet): no box.
39
+ const RESPONSE_STYLE = `
40
+ .form__response { display: flex; flex-direction: row; align-items: center; gap: 8px; margin: 0; cursor: pointer; }
41
+ .form__response input { margin: 0; width: 16px; height: 16px; accent-color: var(--accent-background); cursor: pointer; }`;
42
+ function addResponseBox(root, onChange) {
43
+ const email = root?.querySelector('input[name="email"]:not([type="hidden"])');
44
+ if (!email || root.querySelector('.form__response')) return Boolean(email);
45
+ const style = document.createElement('style');
46
+ style.textContent = RESPONSE_STYLE;
47
+ const box = document.createElement('input');
48
+ box.type = 'checkbox';
49
+ box.name = 'response';
50
+ box.addEventListener('change', () => {
51
+ email.required = box.checked;
52
+ onChange(box.checked);
53
+ });
54
+ const label = document.createElement('label');
55
+ label.className = 'form__response';
56
+ label.append(box, RESPONSE_LABEL);
57
+ (email.closest('label') ?? email).after(label);
58
+ root.append(style);
59
+ return true;
29
60
  }
30
61
 
31
62
  // A path as a pattern: each id in it becomes ":id", so every invoice's page
@@ -101,7 +132,9 @@ export async function startMonitoring({ flow, person }) {
101
132
  beforeSendSpan: scrubSpan,
102
133
  initialScope: { tags: config.tags ?? {} },
103
134
  });
104
- Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
135
+ // Read when the report is sent: the tick box of the form open now.
136
+ let wantsResponse = false;
137
+ Sentry.getClient()?.on('beforeSendFeedback', (event) => tagNotify(event, wantsResponse));
105
138
  // Who reported or met the error, so the lab can follow up.
106
139
  Sentry.setUser({ id: person.id, email: person.email, username: person.name || undefined });
107
140
  const feedback = config.feedback ? Sentry.getFeedback() : null;
@@ -112,8 +145,18 @@ export async function startMonitoring({ flow, person }) {
112
145
  let form = null;
113
146
  return async () => {
114
147
  form?.removeFromDom();
148
+ wantsResponse = false;
115
149
  form = await feedback.createForm();
116
150
  form.appendToDom();
117
151
  form.open();
152
+ // The form draws itself a moment after open(); add the tick box once its
153
+ // email field is there.
154
+ const root = document.getElementById('sentry-feedback')?.shadowRoot;
155
+ const onChange = (checked) => { wantsResponse = checked; };
156
+ if (!addResponseBox(root, onChange) && root) {
157
+ const watch = new MutationObserver(() => { if (addResponseBox(root, onChange)) watch.disconnect(); });
158
+ watch.observe(root, { childList: true, subtree: true });
159
+ setTimeout(() => watch.disconnect(), 5000);
160
+ }
118
161
  };
119
162
  }
package/server/ui/page.js CHANGED
@@ -71,20 +71,125 @@ ${body}
71
71
  `;
72
72
  }
73
73
 
74
- /** A one-message page, such as the end of connecting Xero. */
75
- export function messagePage({ title, text, defaultFamily, tone = 'info' }) {
74
+ // Mode labels on the connected callback card (issue #211). Sandbox is the
75
+ // Demo Company; replay is for development and CI.
76
+ const MODE_LABELS = Object.freeze({ live: 'Live', sandbox: 'Demo', replay: 'Replay' });
77
+
78
+ function modeLabel(mode) {
79
+ if (mode == null || mode === '') return '';
80
+ const key = String(mode).toLowerCase();
81
+ return MODE_LABELS[key] ?? String(mode).replace(/^./, (c) => c.toUpperCase());
82
+ }
83
+
84
+ function lucideIcon(name) {
85
+ return `<span class="fx-lucide" style="--icon: url(/assets/icons/${escapeHtml(name)}.svg)" aria-hidden="true"></span>`;
86
+ }
87
+
88
+ /**
89
+ * A one-message page, such as the end of connecting an integration.
90
+ *
91
+ * Optional `integration` ({ name, logo }) shows Flow's mark beside the
92
+ * maker's logo. Optional `organisation` and `mode` become a status line.
93
+ * On success, `autoReturnSeconds` (default 5 when tone is success and an
94
+ * integration is named) starts a polite countdown and returns to Flow; set
95
+ * it to 0 or false to keep the person on the page. Failure never auto-returns.
96
+ */
97
+ export function messagePage({
98
+ title,
99
+ text,
100
+ defaultFamily,
101
+ tone = 'info',
102
+ integration = null,
103
+ organisation = null,
104
+ mode = null,
105
+ autoReturnSeconds,
106
+ href = '/',
107
+ }) {
108
+ const safeHref = typeof href === 'string' && href.startsWith('/') && !href.startsWith('//') ? href : '/';
109
+ const logo = integration?.logo && /^[a-z0-9][a-z0-9._-]*\.svg$/i.test(integration.logo) ? integration.logo : null;
110
+ const integrationName = integration?.name ? String(integration.name) : '';
111
+ const showMarks = Boolean(logo || integrationName);
112
+ const resultIcon = tone === 'success' ? 'check-circle' : tone === 'danger' ? 'x-circle' : null;
113
+ const org = organisation != null && String(organisation).trim() ? String(organisation).trim() : '';
114
+ const modeText = modeLabel(mode);
115
+ const statusParts = [org, modeText].filter(Boolean);
116
+ const returnSeconds = autoReturnSeconds === false || autoReturnSeconds === 0
117
+ ? 0
118
+ : tone === 'success' && showMarks
119
+ ? Math.max(1, Math.min(30, Number(autoReturnSeconds) || 5))
120
+ : 0;
121
+
122
+ const marks = showMarks
123
+ ? `<div class="fx-message__marks" aria-hidden="true">
124
+ <span class="fx-logo fx-logo--lg fx-message__flow">${FLOW_MARK}</span>
125
+ <span class="fx-message__plus">+</span>
126
+ <span class="fx-logo fx-logo--lg">${logo
127
+ ? `<img src="/assets/ui/logos/${escapeHtml(logo)}" alt="">`
128
+ : lucideIcon('plug')}</span>
129
+ ${resultIcon ? `<span class="fx-message__result" data-tone="${escapeHtml(tone)}">${lucideIcon(resultIcon)}</span>` : ''}
130
+ </div>`
131
+ : resultIcon
132
+ ? `<div class="fx-message__marks fx-message__marks--solo" aria-hidden="true">
133
+ <span class="fx-message__result" data-tone="${escapeHtml(tone)}">${lucideIcon(resultIcon)}</span>
134
+ </div>`
135
+ : '';
136
+
137
+ const status = statusParts.length
138
+ ? `<p class="fx-message__status"><span class="fx-status" data-tone="${escapeHtml(tone === 'danger' ? 'danger' : 'success')}">${escapeHtml(statusParts.join(' · '))}</span></p>`
139
+ : '';
140
+
141
+ const returnLine = returnSeconds
142
+ ? `<p class="fx-message__return" hidden aria-live="polite" data-return-after="${returnSeconds}" data-return-href="${escapeHtml(safeHref)}"></p>`
143
+ : '';
144
+
145
+ // Clean the OAuth query from the address. On success only, count down and
146
+ // return to Flow. Without JavaScript the Back button is enough.
147
+ const cleanScript = showMarks || returnSeconds
148
+ ? `<script>try{history.replaceState(null,'',location.pathname)}catch(e){}</script>`
149
+ : '';
150
+ const returnScript = returnSeconds
151
+ ? `<script>
152
+ (function () {
153
+ var el = document.querySelector('[data-return-after]');
154
+ if (!el) return;
155
+ var left = Number(el.getAttribute('data-return-after')) || 0;
156
+ var href = el.getAttribute('data-return-href') || '/';
157
+ if (left < 1) return;
158
+ el.hidden = false;
159
+ function say(n) {
160
+ el.textContent = n === 1 ? 'Going back to Flow in 1 second' : 'Going back to Flow in ' + n + ' seconds';
161
+ }
162
+ say(left);
163
+ var timer = setInterval(function () {
164
+ left -= 1;
165
+ if (left <= 0) {
166
+ clearInterval(timer);
167
+ location.assign(href);
168
+ return;
169
+ }
170
+ say(left);
171
+ }, 1000);
172
+ })();
173
+ </script>`
174
+ : '';
175
+
76
176
  return renderPage({
77
177
  title,
78
178
  defaultFamily,
79
179
  bodyClass: 'fx-centre',
80
180
  scripts: ['/assets/ui/appearance.js'],
81
181
  body: `<main class="fx-message">
82
- <a class="fx-brand" href="/">${FLOW_MARK}<span>Flow</span></a>
182
+ <a class="fx-brand" href="${escapeHtml(safeHref)}">${FLOW_MARK}<span>Flow</span></a>
83
183
  <section class="fx-card fx-message__card" data-tone="${escapeHtml(tone)}">
184
+ ${marks}
84
185
  <h1 class="fx-title">${escapeHtml(title)}</h1>
186
+ ${status}
85
187
  <p>${escapeHtml(text)}</p>
86
- <a class="fx-btn" href="/">Back to Flow</a>
188
+ ${returnLine}
189
+ <a class="fx-btn" href="${escapeHtml(safeHref)}">Back to Flow</a>
87
190
  </section>
88
- </main>`,
191
+ </main>
192
+ ${cleanScript}
193
+ ${returnScript}`,
89
194
  });
90
195
  }
@@ -0,0 +1,67 @@
1
+ // The pages of a long list, in the list window's foot bar (listFrame), as
2
+ // HubSpot's: rows per page, "1–100 of 1,204", and numbered pages between
3
+ // previous and next. The server cuts the page (GET /api/invoicing/invoices
4
+ // takes offset and limit), so a list works with any number of rows.
5
+ //
6
+ // listPager(h, { label, sizes, change }) answers { element, draw(page) }.
7
+ // page = { offset, limit, total }. change({ offset, limit }) asks for a
8
+ // page; a new size starts again at the first page. One page or none hides
9
+ // the page numbers; the size stays, so a person can show more per page.
10
+ // Issue #220 brings the pager over one record (.fx-pager) into this file.
11
+
12
+ export const PAGE_SIZES = Object.freeze([25, 50, 100, 200]);
13
+
14
+ const chevron = (d) => `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${d}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
15
+ const PREV = chevron('M14.5 6l-6 6 6 6');
16
+ const NEXT = chevron('M9.5 6l6 6-6 6');
17
+ const count = new Intl.NumberFormat('en-AU');
18
+
19
+ // The page numbers to show: the first, the last, and two each side of the
20
+ // one shown, with '…' for a gap. Pages count from 1.
21
+ export function pageNumbers(at, pages) {
22
+ if (pages <= 7) return Array.from({ length: pages }, (_, n) => n + 1);
23
+ const near = new Set([1, pages, at - 1, at, at + 1]);
24
+ if (at <= 3) [2, 3, 4].forEach((n) => near.add(n));
25
+ if (at >= pages - 2) [pages - 3, pages - 2, pages - 1].forEach((n) => near.add(n));
26
+ const list = [...near].filter((n) => n >= 1 && n <= pages).sort((a, b) => a - b);
27
+ return list.flatMap((n, i) => (i && n - list[i - 1] > 1 ? ['…', n] : [n]));
28
+ }
29
+
30
+ // "1–100 of 1,204", or "0 of 0".
31
+ export function pageRange({ offset, limit, total }) {
32
+ if (!total) return '0 of 0';
33
+ return `${count.format(offset + 1)}–${count.format(Math.min(offset + limit, total))} of ${count.format(total)}`;
34
+ }
35
+
36
+ export function listPager(h, { label = 'Pages', sizes = PAGE_SIZES, change }) {
37
+ const element = h('nav', { class: 'fx-pages', 'aria-label': label });
38
+ const icon = (markup) => { const span = h('span', { 'aria-hidden': 'true' }); span.innerHTML = markup; return span; };
39
+
40
+ function draw({ offset = 0, limit = sizes[0], total = 0 }) {
41
+ const pages = Math.max(1, Math.ceil(total / limit));
42
+ const at = Math.min(pages, Math.floor(offset / limit) + 1);
43
+ const go = (n) => change({ offset: (n - 1) * limit, limit });
44
+ const size = h('select', {
45
+ class: 'fx-select fx-pages__size', 'aria-label': 'Rows per page', title: 'Rows per page',
46
+ onchange: (event) => change({ offset: 0, limit: Number(event.target.value) }),
47
+ }, sizes.map((n) => h('option', { value: String(n), ...(n === limit ? { selected: true } : {}) }, String(n))));
48
+ const step = (n, markup, name) => h('button', {
49
+ type: 'button', class: 'fx-pages__step', 'aria-label': name, title: name,
50
+ ...(n < 1 || n > pages ? { disabled: true } : {}), onclick: () => go(n),
51
+ }, icon(markup));
52
+ element.replaceChildren(
53
+ h('label', { class: 'fx-pages__rows' }, 'Rows', size),
54
+ h('span', { class: 'fx-pages__range' }, pageRange({ offset, limit, total })),
55
+ pages > 1 ? h('span', { class: 'fx-pages__steps' },
56
+ step(at - 1, PREV, 'Previous page'),
57
+ pageNumbers(at, pages).map((n) => (n === '…'
58
+ ? h('span', { class: 'fx-pages__gap', 'aria-hidden': 'true' }, '…')
59
+ : h('button', {
60
+ type: 'button', class: 'fx-pages__num', 'aria-label': `Page ${n}`,
61
+ ...(n === at ? { 'aria-current': 'page' } : {}), onclick: () => { if (n !== at) go(n); },
62
+ }, String(n)))),
63
+ step(at + 1, NEXT, 'Next page')) : '');
64
+ }
65
+
66
+ return { element, draw };
67
+ }
@@ -7,6 +7,8 @@
7
7
  // records need a person and opens the work. Everyone can read it; an owner or
8
8
  // admin changes it. The caller passes in app.js's shared pieces as `ui`.
9
9
  import { alertStrip } from '/assets/ui/alerts.js';
10
+ import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
11
+ import { normaliseListView, PRICE_COLUMNS } from '/assets/ui/job-view.js';
10
12
  import { reconcileRows } from '/assets/ui/price-match.js';
11
13
 
12
14
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/PRICE_LIST.md';
@@ -16,6 +18,15 @@ const LINE_PRICES = [
16
18
  { value: 'list', label: 'Price list' },
17
19
  ];
18
20
 
21
+ // The list's columns (columns.js): each keeps its own width, saved with the
22
+ // person's account; + shows a hidden one again.
23
+ const COLUMN_DEFS = {
24
+ code: { kind: 'number', className: 'fx-price__code', cell: (item) => item.code },
25
+ name: { kind: 'client', cell: (item) => item.name },
26
+ price: { kind: 'total', num: true, cell: (item) => money(item.unitAmountCents) },
27
+ tax: { kind: 'case', className: 'fx-dim', cell: (item) => TAX_LABELS[item.taxCode] ?? item.taxCode },
28
+ };
29
+
19
30
  const money = (cents) => (cents === null || cents === undefined ? '' : `$${(cents / 100).toFixed(2)}`);
20
31
  const toCents = (text) => {
21
32
  const value = Number(String(text ?? '').replace(/[$,\s]/g, ''));
@@ -26,6 +37,18 @@ export async function renderPriceList(main, ui) {
26
37
  const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
27
38
  const disabled = !['owner', 'admin'].includes(me?.person.role);
28
39
  const host = h('div', { class: 'fx-price' }, h('div', { class: 'fx-skeleton' }));
40
+ let view = normaliseListView(me?.views?.['price-list'], PRICE_COLUMNS);
41
+ const saveView = viewSaver(ui, 'price-list');
42
+ const heads = columnTable(h, {
43
+ order: () => view.columns,
44
+ widths: () => view.widths,
45
+ change: (patch) => {
46
+ view = normaliseListView({ ...view, ...patch }, PRICE_COLUMNS);
47
+ saveView(view);
48
+ draw();
49
+ },
50
+ hidden: () => Object.keys(PRICE_COLUMNS).filter((id) => !view.columns.includes(id)).map((id) => ({ id, label: PRICE_COLUMNS[id] })),
51
+ });
29
52
  const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Add code');
30
53
  const reconcileButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', hidden: true, onclick: () => ui.go('/price-list/reconcile') }, 'Reconcile');
31
54
  const alerts = alertStrip(ui, 'price-list');
@@ -160,15 +183,12 @@ export async function renderPriceList(main, ui) {
160
183
  const tabs = h('div', { class: 'fx-tabs', role: 'tablist' }, [['active', 'Active', live.length], ['archived', 'Archived', archived.length]].map(([id, label, count]) => h('button', {
161
184
  type: 'button', role: 'tab', 'aria-selected': String(tab === id), onclick: () => { tab = id; draw(); },
162
185
  }, label, h('span', { class: 'fx-tabs__count' }, String(count)))));
186
+ const columns = view.columns.map((id) => ({ id, label: PRICE_COLUMNS[id], ...COLUMN_DEFS[id] }));
163
187
  const itemRow = (item) => h('tr', { onclick: () => edit(item), tabindex: '0', onkeydown: (event) => { if (event.key === 'Enter') edit(item); } },
164
- h('td', { class: 'fx-price__code' }, item.code),
165
- h('td', {}, item.name),
166
- h('td', { class: 'fx-num' }, money(item.unitAmountCents)),
167
- h('td', { class: 'fx-dim' }, TAX_LABELS[item.taxCode] ?? item.taxCode));
188
+ rowCells(h, columns, item),
189
+ h('td', { class: 'fx-c-end' }));
168
190
  const table = shown.length
169
- ? h('div', { class: 'fx-price__scroll' }, h('table', { class: 'fx-table fx-table--rows fx-price__table' },
170
- h('thead', {}, h('tr', {}, h('th', {}, 'Code'), h('th', {}, 'Name'), h('th', { class: 'fx-num' }, 'Price'), h('th', {}, 'Tax'))),
171
- h('tbody', {}, shown.map(itemRow))))
191
+ ? h('div', { class: 'fx-panel' }, heads.table({ className: 'fx-table--rows fx-price__table', columns, rows: h('tbody', {}, shown.map(itemRow)) }))
172
192
  : h('p', { class: 'fx-empty' }, words ? 'No code matches.' : tab === 'archived' ? 'No archived codes.' : 'No codes yet. Add the first one.');
173
193
  const sections = [
174
194
  disabled ? notice('Only an owner or admin can change these.', 'info') : null,
@@ -204,7 +204,7 @@ async function renderList(main, ui) {
204
204
  const host = h('div', { class: 'fx-rec' }, h('div', { class: 'fx-skeleton' }));
205
205
  const bulk = bulkBar(ui, { label: 'Ticked jobs' });
206
206
  const frame = listFrame(main, host);
207
- main.append(...listHead('Reconcile', { views: views.element, start: controls, end: find.element }).nodes, frame.element, bulk.element);
207
+ main.append(...listHead('Reconcile', { views: views.element, controls, end: find.element }).nodes, frame.element, bulk.element);
208
208
 
209
209
  // The Filter chip: the same panel as on Jobs (view-bar.js). Job rows also
210
210
  // have the lab's custom fields.
@@ -21,7 +21,7 @@ const themeDir = join(here, '..', 'theme');
21
21
  const logoDir = join(here, 'logos');
22
22
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
23
23
  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', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.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', 'person.js', 'flow-mark.svg']);
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', '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', 'person.js', 'pager.js', 'flow-mark.svg']);
25
25
  const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
26
26
  const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
27
27
  const MAX_AGE = '1h';