@arcforgelabs/flow 2026.10.45 → 2026.10.47

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 (49) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +104 -2
  4. package/package.json +6 -2
  5. package/server/api/invoicing.js +56 -16
  6. package/server/app.js +4 -2
  7. package/server/auth/people.js +5 -3
  8. package/server/auth/routes.js +4 -1
  9. package/server/core/invoice-rules.js +34 -4
  10. package/server/core/invoices.js +40 -1
  11. package/server/core/invoicing-rules.js +20 -3
  12. package/server/core/job-board.js +1 -1
  13. package/server/core/sending.js +76 -22
  14. package/server/integrations/call-guard.js +21 -4
  15. package/server/integrations/sentry/index.js +14 -7
  16. package/server/integrations/xero/client.js +12 -2
  17. package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
  18. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  19. package/server/integrations/xero/routes.js +29 -5
  20. package/server/integrations/xero/runtime.js +5 -0
  21. package/server/integrations/xero/usage.js +74 -0
  22. package/server/ui/alerts.js +2 -1
  23. package/server/ui/app.js +110 -27
  24. package/server/ui/bulk-bar.js +18 -5
  25. package/server/ui/columns.js +54 -2
  26. package/server/ui/custom-fields.js +22 -7
  27. package/server/ui/flow.css +353 -45
  28. package/server/ui/invoicing.js +286 -64
  29. package/server/ui/job-board.js +10 -0
  30. package/server/ui/job-change.js +4 -3
  31. package/server/ui/job-view.js +14 -4
  32. package/server/ui/jobs.js +156 -56
  33. package/server/ui/kit.js +212 -51
  34. package/server/ui/list-views.js +50 -10
  35. package/server/ui/mobile.js +726 -0
  36. package/server/ui/monitoring.js +51 -0
  37. package/server/ui/nav-pins.js +41 -0
  38. package/server/ui/page.js +110 -5
  39. package/server/ui/pager.js +57 -10
  40. package/server/ui/price-list.js +1 -1
  41. package/server/ui/price-reconcile.js +12 -12
  42. package/server/ui/reconcile.js +19 -18
  43. package/server/ui/routes.js +1 -1
  44. package/server/ui/saved-views.js +6 -5
  45. package/server/ui/sidecar.js +106 -6
  46. package/server/ui/view-bar.js +22 -11
  47. package/server/ui/xero-import.js +48 -19
  48. package/server/ui/xero-usage-model.js +106 -0
  49. package/server/ui/xero-usage.js +133 -0
@@ -4,6 +4,13 @@
4
4
  // GET /api/monitoring says so and gives the project's public DSN. The SDK
5
5
  // comes from Sentry's CDN, pinned and checked against its integrity hash.
6
6
  // With the integration off, nothing loads and there is no "Report a problem".
7
+ //
8
+ // Replay (SENTRY_REPLAY, on by default): the SDK keeps the last minute of the
9
+ // page in the browser's memory and sends it only with a report or a browser
10
+ // error, so a report shows the open menu or hover that the click on "Report a
11
+ // problem" closed. Flow shows customers' and patients' names, addresses and
12
+ // prices: a replay keeps the page's shape, the pointer and the clicks, never
13
+ // its words.
7
14
 
8
15
  function loadScript({ src, integrity }) {
9
16
  return new Promise((resolve, reject) => {
@@ -89,6 +96,36 @@ export function scrubSpan(span) {
89
96
  return span;
90
97
  }
91
98
 
99
+ // Elements a replay never shows: frames, canvases and embedded objects, and
100
+ // links that carry an address, a number or a query.
101
+ const REPLAY_BLOCK = ['iframe', 'canvas', 'embed', 'object', 'a[href^="mailto:" i]', 'a[href^="tel:" i]', 'a[href^="sms:" i]', 'a[href*="?"]'];
102
+ // Attributes that can carry a name; masked like the text.
103
+ const REPLAY_MASKED_ATTRIBUTES = ['title', 'placeholder', 'aria-label', 'aria-description', 'alt', 'download', 'value', 'data-value'];
104
+ // What a replay keeps of a clicked element.
105
+ const KEPT_NODE_ATTRIBUTES = new Set(['id', 'class', 'role']);
106
+ const withoutQuery = (value) => (typeof value === 'string' ? value.replace(/[?#].*$/, '') : value);
107
+
108
+ // One event of a replay's recording, before it is kept. A click names its
109
+ // element by CSS path, and the path can quote an attribute's value: the
110
+ // values go, and of the element only its id, class and role stay. A page's
111
+ // address keeps its path, never its query string.
112
+ export function scrubRecordingEvent(event) {
113
+ const payload = event?.data?.payload;
114
+ if (!payload || typeof payload !== 'object') return event;
115
+ const next = { ...payload };
116
+ if (typeof next.category === 'string' && next.category.startsWith('ui.')) {
117
+ if (typeof next.message === 'string') next.message = next.message.replace(/\[[^\]]*\]/g, '');
118
+ const node = next.data?.node;
119
+ if (node) {
120
+ const attributes = Object.fromEntries(Object.entries(node.attributes ?? {}).filter(([name]) => KEPT_NODE_ATTRIBUTES.has(name)));
121
+ next.data = { ...next.data, node: { ...node, attributes, textContent: '' } };
122
+ }
123
+ }
124
+ if (next.category === 'navigation' && next.data) next.data = { ...next.data, from: withoutQuery(next.data.from), to: withoutQuery(next.data.to) };
125
+ if (typeof next.description === 'string') next.description = withoutQuery(next.description);
126
+ return { ...event, data: { ...event.data, payload: next } };
127
+ }
128
+
92
129
  /** Resolves to a function that opens the "Report a problem" form, or null. */
93
130
  export async function startMonitoring({ flow, person }) {
94
131
  const config = await flow('/api/monitoring');
@@ -113,6 +150,17 @@ export async function startMonitoring({ flow, person }) {
113
150
  submitButtonLabel: 'Send report',
114
151
  successMessageText: 'Thank you. Your report is sent.',
115
152
  })] : [];
153
+ if (config.replay && Sentry.replayIntegration) {
154
+ integrations.push(Sentry.replayIntegration({
155
+ maskAllText: true,
156
+ maskAllInputs: true,
157
+ blockAllMedia: true,
158
+ maskAttributes: REPLAY_MASKED_ATTRIBUTES,
159
+ block: REPLAY_BLOCK,
160
+ networkDetailAllowUrls: [],
161
+ beforeAddRecordingEvent: scrubRecordingEvent,
162
+ }));
163
+ }
116
164
  // Traces: page loads, moves between pages and each click's wait, with the
117
165
  // calls to Flow's server joined to the server's own trace. Only when the
118
166
  // installation set a share above 0 (SENTRY_TRACES_SAMPLE_RATE).
@@ -129,6 +177,9 @@ export async function startMonitoring({ flow, person }) {
129
177
  sendDefaultPii: false,
130
178
  integrations,
131
179
  tracesSampleRate,
180
+ // Replay: never a whole visit, only the minute before a report or error.
181
+ replaysSessionSampleRate: 0,
182
+ replaysOnErrorSampleRate: config.replay ? 1 : 0,
132
183
  beforeSendSpan: scrubSpan,
133
184
  initialScope: { tags: config.tags ?? {} },
134
185
  });
@@ -0,0 +1,41 @@
1
+ // Which sidebar pages a person has pinned. Stored with the account in
2
+ // people.views.nav (PUT /api/me/views/nav). No table of its own.
3
+ // Jobs and Invoices stay pinned: a request that leaves them out is saved
4
+ // with them put back. A page Flow does not know is dropped.
5
+
6
+ export const NAV_PAGES = Object.freeze([
7
+ 'jobs',
8
+ 'invoices',
9
+ 'reconcile',
10
+ 'price-list',
11
+ 'invoicing-settings',
12
+ 'automations',
13
+ 'custom-fields',
14
+ 'integrations',
15
+ ]);
16
+
17
+ export const NAV_LOCKED = Object.freeze(['jobs', 'invoices']);
18
+
19
+ // Today's sidebar, before a person has chosen: daily work, Price list and
20
+ // Rules. Automations, Custom fields and Integrations start in More.
21
+ export const NAV_DEFAULT = Object.freeze([
22
+ 'jobs',
23
+ 'invoices',
24
+ 'reconcile',
25
+ 'price-list',
26
+ 'invoicing-settings',
27
+ ]);
28
+
29
+ export function normaliseNavPins(input) {
30
+ const source = Array.isArray(input?.pinned) ? input.pinned : [];
31
+ const chosen = new Set(NAV_LOCKED);
32
+ for (const name of source) if (NAV_PAGES.includes(name)) chosen.add(name);
33
+ return { pinned: NAV_PAGES.filter((name) => chosen.has(name)) };
34
+ }
35
+
36
+ // No saved choice yet: the default sidebar. A saved choice is checked.
37
+ export function navPinsFromViews(input) {
38
+ const value = input && typeof input === 'object' ? input : {};
39
+ if (!value.nav) return { pinned: [...NAV_DEFAULT] };
40
+ return normaliseNavPins(value.nav);
41
+ }
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
  }
@@ -1,13 +1,23 @@
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.
1
+ // Two pagers live here:
5
2
  //
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.
3
+ // listPager — the pages of a long list, in the list window's foot bar
4
+ // (listFrame), as HubSpot's: rows per page, "1–100 of 1,204", and numbered
5
+ // pages between previous and next. The server cuts the page
6
+ // (GET /api/invoicing/invoices takes offset and limit), so a list works with
7
+ // any number of rows. listPager(h, { label, sizes, change }) answers
8
+ // { element, draw(page) }. page = { offset, limit, total }.
9
+ // change({ offset, limit }) asks for a page; a new size starts again at the
10
+ // first page. One page or none hides the page numbers; the size stays, so a
11
+ // person can show more per page.
12
+ //
13
+ // pager — ".fx-pager" over one record opened from a list: "← Back", "n of N",
14
+ // previous and next. pager(h, { label, back, … }) answers { element, draw }.
15
+ // draw({ count, prev, next, prevLabel, nextLabel }) fills the count and
16
+ // steps; omit draw (or draw with steps: false) for Back only, as when an
17
+ // invoice is not yet in the list. Invoices, Reconcile and Price list ›
18
+ // Reconcile use it (issue #220).
19
+
20
+ import { applyPhoneRule } from './mobile.js';
11
21
 
12
22
  export const PAGE_SIZES = Object.freeze([25, 50, 100, 200]);
13
23
 
@@ -16,6 +26,43 @@ const PREV = chevron('M14.5 6l-6 6 6 6');
16
26
  const NEXT = chevron('M9.5 6l6 6-6 6');
17
27
  const count = new Intl.NumberFormat('en-AU');
18
28
 
29
+ // The bar over one record: Back, optional "n of N", previous and next.
30
+ export function pager(h, { label = 'Records', back }) {
31
+ const backBtn = h('button', { class: 'fx-pager__back', type: 'button', onclick: back }, '← Back');
32
+ const element = h('nav', { class: 'fx-pager', 'aria-label': label }, backBtn);
33
+
34
+ function draw({
35
+ count: text = '',
36
+ prev = null,
37
+ next = null,
38
+ prevLabel = 'Previous',
39
+ nextLabel = 'Next',
40
+ steps = true,
41
+ } = {}) {
42
+ if (!steps) {
43
+ element.replaceChildren(backBtn);
44
+ return;
45
+ }
46
+ const step = (go, name, markup) => {
47
+ const button = h('button', {
48
+ class: 'fx-pager__step', type: 'button',
49
+ 'aria-label': name, title: name,
50
+ ...(!go ? { disabled: true } : { onclick: go }),
51
+ });
52
+ button.innerHTML = markup;
53
+ return button;
54
+ };
55
+ element.replaceChildren(
56
+ backBtn,
57
+ h('span', { class: 'fx-pager__count' }, text),
58
+ h('span', { class: 'fx-pager__steps' },
59
+ step(prev, prevLabel, PREV),
60
+ step(next, nextLabel, NEXT)));
61
+ }
62
+
63
+ return { element, draw };
64
+ }
65
+
19
66
  // The page numbers to show: the first, the last, and two each side of the
20
67
  // one shown, with '…' for a gap. Pages count from 1.
21
68
  export function pageNumbers(at, pages) {
@@ -34,7 +81,7 @@ export function pageRange({ offset, limit, total }) {
34
81
  }
35
82
 
36
83
  export function listPager(h, { label = 'Pages', sizes = PAGE_SIZES, change }) {
37
- const element = h('nav', { class: 'fx-pages', 'aria-label': label });
84
+ const element = applyPhoneRule(h('nav', { class: 'fx-pages', 'aria-label': label }), 'listPager');
38
85
  const icon = (markup) => { const span = h('span', { 'aria-hidden': 'true' }); span.innerHTML = markup; return span; };
39
86
 
40
87
  function draw({ offset = 0, limit = sizes[0], total = 0 }) {
@@ -22,7 +22,7 @@ const LINE_PRICES = [
22
22
  // person's account; + shows a hidden one again.
23
23
  const COLUMN_DEFS = {
24
24
  code: { kind: 'number', className: 'fx-price__code', cell: (item) => item.code },
25
- name: { kind: 'client', cell: (item) => item.name },
25
+ name: { kind: 'client', phone: 'title', cell: (item) => item.name },
26
26
  price: { kind: 'total', num: true, cell: (item) => money(item.unitAmountCents) },
27
27
  tax: { kind: 'case', className: 'fx-dim', cell: (item) => TAX_LABELS[item.taxCode] ?? item.taxCode },
28
28
  };
@@ -13,6 +13,7 @@
13
13
  // The price check itself runs on each pipeline pass, so there is no button.
14
14
  import { bulkBar } from '/assets/ui/bulk-bar.js';
15
15
  import { lucide } from '/assets/ui/custom-fields.js';
16
+ import { pager as recordPager } from '/assets/ui/pager.js';
16
17
  import { findCodes, nameScore, reconcileRows, SAME_NAME } from '/assets/ui/price-match.js';
17
18
 
18
19
  const TAX_LABELS = { GST: 'GST', GST_FREE: 'GST free' };
@@ -166,18 +167,17 @@ export async function renderPriceReconcile(main, ui) {
166
167
 
167
168
  // The invoice pager: Back to the price list, "n of N", previous and next.
168
169
  function pager() {
169
- const step = (to, label, path) => {
170
- const button = h('button', {
171
- class: 'fx-pager__step', type: 'button', disabled: to < 0 || to >= rows.length, 'aria-label': label, title: label,
172
- onclick: () => { at = to; pick = 0; chosen = null; drawCard(); },
173
- });
174
- button.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
175
- return button;
176
- };
177
- return h('nav', { class: 'fx-pager', 'aria-label': 'Records to check' },
178
- h('button', { class: 'fx-pager__back', type: 'button', onclick: () => ui.go('/price-list') }, '← Back'),
179
- h('span', { class: 'fx-pager__count' }, `${at + 1} of ${rows.length}`),
180
- h('span', { class: 'fx-pager__steps' }, step(at - 1, 'Previous', 'M14.5 6l-6 6 6 6'), step(at + 1, 'Next', 'M9.5 6l6 6-6 6')));
170
+ const go = (to) => () => { at = to; pick = 0; chosen = null; drawCard(); };
171
+ const { element, draw } = recordPager(h, {
172
+ label: 'Records to check',
173
+ back: () => ui.go('/price-list'),
174
+ });
175
+ draw({
176
+ count: `${at + 1} of ${rows.length}`,
177
+ prev: at > 0 ? go(at - 1) : null,
178
+ next: at < rows.length - 1 ? go(at + 1) : null,
179
+ });
180
+ return element;
181
181
  }
182
182
 
183
183
  // One record (or one differing code) at a time.
@@ -22,6 +22,7 @@ import { statusRank } from '/assets/ui/job-board.js';
22
22
  import { filterJobs, KIND_OF_TYPE, normaliseListView, RECONCILE_COLUMNS } from '/assets/ui/job-view.js';
23
23
  import { normaliseReconcileView, RECONCILE_FILTER_FIELDS } from '/assets/ui/list-views.js';
24
24
  import { savedViews } from '/assets/ui/saved-views.js';
25
+ import { pager } from '/assets/ui/pager.js';
25
26
  import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
26
27
  import { formatCents } from '/assets/ui/invoicing.js';
27
28
 
@@ -95,7 +96,6 @@ function jobCandidateFacts(job) {
95
96
  }
96
97
 
97
98
 
98
- const CHEVRONS = { prev: 'M14.5 6l-6 6 6 6', next: 'M9.5 6l6 6-6 6' };
99
99
  // The list's filter rows (view-bar.js), kept per viewer in this browser.
100
100
  // The list a person opened a gap from, for the pager.
101
101
  const FILTER_KEY = 'flow_reconcile_filters';
@@ -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.
@@ -306,12 +306,12 @@ async function renderList(main, ui) {
306
306
  ? h('span', { class: 'fx-rec__possible', 'data-state': 'some' }, icon('link-2', 'Possible match'), String(r.possible))
307
307
  : h('span', { class: 'fx-rec__possible' }, icon('circle-dashed', 'No possible match')));
308
308
  const ALL = {
309
- kind: { kind: 'icon', icon: 'layers', className: 'fx-rec-list__kind', cell: (r) => icon(...KIND_ICON[r.kind]) },
310
- client: { kind: 'client', className: 'fx-strong', cell: (r) => r.client ?? h('span', { class: 'fx-dim' }, 'No client') },
311
- detail: { kind: 'text', cell: (r) => h('span', { class: r.kind === 'invoice' ? 'fx-num' : 'fx-dim' }, r.who ?? '—', r.kind === 'invoice' ? h('span', { class: 'fx-dim' }, ` · ${statusWords(r.status)}`) : null) },
312
- date: { kind: 'date', className: 'fx-dim', cell: (r) => shortDate(r.date) },
313
- total: { kind: 'total', num: true, cell: (r) => money(r.totalCents) },
314
- possible: { kind: 'status', className: 'fx-rec-list__mid', cell: possible },
309
+ kind: { kind: 'icon', phone: 'meta', icon: 'layers', className: 'fx-rec-list__kind', cell: (r) => icon(...KIND_ICON[r.kind]) },
310
+ client: { kind: 'client', phone: 'title', className: 'fx-strong', cell: (r) => r.client ?? h('span', { class: 'fx-dim' }, 'No client') },
311
+ detail: { kind: 'text', phone: 'meta', cell: (r) => h('span', { class: r.kind === 'invoice' ? 'fx-num' : 'fx-dim' }, r.who ?? '—', r.kind === 'invoice' ? h('span', { class: 'fx-dim' }, ` · ${statusWords(r.status)}`) : null) },
312
+ date: { kind: 'date', phone: 'hide', className: 'fx-dim', cell: (r) => shortDate(r.date) },
313
+ total: { kind: 'total', phone: 'end', num: true, cell: (r) => money(r.totalCents) },
314
+ possible: { kind: 'status', phone: 'meta', className: 'fx-rec-list__mid', cell: possible },
315
315
  };
316
316
  const columns = state.columns.columns.map((id) => ({ id, label: RECONCILE_COLUMNS[id], ...ALL[id] }));
317
317
  host.replaceChildren(h('div', { class: 'fx-panel' }, heads.table({
@@ -351,15 +351,16 @@ async function renderGap(main, ui, id) {
351
351
  order = lists ? reconcileRows(lists).filter((r) => r.kind === kind).map(({ kind: k, id: i }) => ({ kind: k, id: i })) : [];
352
352
  }
353
353
  const at = order.findIndex((r) => r.id === id);
354
- const step = (to, label, path) => {
355
- const button = h('button', { class: 'fx-pager__step', type: 'button', disabled: !to, 'aria-label': label, title: label, onclick: () => ui.go(pathOf(to)) });
356
- button.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${path}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
357
- return button;
358
- };
359
- const pager = h('nav', { class: 'fx-pager', 'aria-label': 'Reconcile' },
360
- h('button', { class: 'fx-pager__back', type: 'button', onclick: () => ui.go('/reconcile') }, '← Back'),
361
- h('span', { class: 'fx-pager__count' }, at >= 0 ? `${at + 1} of ${order.length}` : ''),
362
- h('span', { class: 'fx-pager__steps' }, step(at > 0 ? order[at - 1] : null, 'Previous', CHEVRONS.prev), step(at >= 0 ? order[at + 1] : null, 'Next', CHEVRONS.next)));
354
+ const { element: pagerBar, draw: drawPager } = pager(h, {
355
+ label: 'Reconcile',
356
+ back: () => ui.go('/reconcile'),
357
+ });
358
+ const goRow = (row) => (row ? () => ui.go(pathOf(row)) : null);
359
+ drawPager({
360
+ count: at >= 0 ? `${at + 1} of ${order.length}` : '',
361
+ prev: goRow(at > 0 ? order[at - 1] : null),
362
+ next: goRow(at >= 0 ? order[at + 1] : null),
363
+ });
363
364
  const next = () => {
364
365
  const rest = order.filter((r) => r.id !== id);
365
366
  writeStored(sessionStorage, NAV_KEY, { ...readStored(sessionStorage, NAV_KEY, {}), rows: rest });
@@ -380,7 +381,7 @@ async function renderGap(main, ui, id) {
380
381
  },
381
382
  });
382
383
  const card = h('section', { class: 'fx-rec fx-rec__gap', 'aria-label': 'Compare' },
383
- pager,
384
+ pagerBar,
384
385
  h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, title, sub), actions),
385
386
  // The search sits where it does on Jobs and Invoices: the right of the
386
387
  // toolbar over the rows it searches.
@@ -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', 'pager.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', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.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';
@@ -1,5 +1,5 @@
1
- // Saved views: the row of named view tabs over a list (Jobs, Invoices,
2
- // Reconcile), as ClickUp's. A view keeps the page's Group, Sort, Filter and
1
+ // Saved views: the named view tabs of a list (Jobs, Invoices, Reconcile),
2
+ // as ClickUp's, in the toolbar beside Group, Sort and Filter (listHead). A view keeps the page's Group, Sort, Filter and
3
3
  // Columns under a name. A tab shows it; "Save" and "Revert" show when the
4
4
  // page no longer matches the chosen tab; "+ View" saves the page as a new
5
5
  // view. Each tab's ⋯ menu has "Set as my default" (the view that opens
@@ -7,13 +7,14 @@
7
7
  // the person's account (PUT /api/me/saved-views/:page, list-views.js).
8
8
  //
9
9
  // const views = savedViews(ui, { page, current, apply });
10
- // views.element the tab row, for the page to place
10
+ // views.element the tabs, for listHead's `views`
11
11
  // views.opening() the view to open on, or null: the default view on the
12
12
  // first visit in this browser tab, else the page's own
13
13
  // views.draw() call after any change to the page's view
14
14
  import { lucide } from '/assets/ui/custom-fields.js';
15
15
  import { rowMenu } from '/assets/ui/kit.js';
16
16
  import { MAX_SAVED_VIEWS, normaliseSavedViews, sameView } from '/assets/ui/list-views.js';
17
+ import { applyPhoneRule } from './mobile.js';
17
18
 
18
19
  const ACTIVE_KEY = (page) => `flow_view_tab_${page}`;
19
20
 
@@ -33,7 +34,7 @@ export function savedViews(ui, { page, current, apply }) {
33
34
  const { h, flow, dialog, toast } = ui;
34
35
  let saved = normaliseSavedViews(page, ui.me?.views?.saved?.[page]);
35
36
  let active = null;
36
- const element = h('div', { class: 'fx-views', role: 'tablist', 'aria-label': 'Views' });
37
+ const element = applyPhoneRule(h('div', { class: 'fx-views', role: 'tablist', 'aria-label': 'Views' }), 'savedViews');
37
38
  const chosen = () => saved.list.find((v) => v.id === active) ?? null;
38
39
 
39
40
  async function store(next) {
@@ -117,7 +118,7 @@ export function savedViews(ui, { page, current, apply }) {
117
118
  });
118
119
  element.replaceChildren(...[
119
120
  ...tabs,
120
- saved.list.length < MAX_SAVED_VIEWS ? h('button', { type: 'button', class: 'fx-views__add', onclick: addView }, lucide(h, 'plus'), 'View') : null,
121
+ 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,
121
122
  changed ? h('span', { class: 'fx-views__end' },
122
123
  h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick: () => { apply(view.view); draw(); } }, 'Revert'),
123
124
  h('button', { type: 'button', class: 'fx-btn fx-btn--primary fx-btn--sm', onclick: () => store({ ...saved, list: saved.list.map((v) => (v.id === view.id ? { ...v, view: current() } : v)) }) }, 'Save')) : null,