@arcforgelabs/flow 2026.10.44 → 2026.10.45

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.44",
3
+ "version": "2026.10.45",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.44",
9
+ "version": "2026.10.45",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.44",
3
+ "version": "2026.10.45",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,5 +1,6 @@
1
1
  import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
2
2
  import express from 'express';
3
+ import { invoicePage, INVOICE_SORTS, normaliseInvoicesView } from '../ui/list-views.js';
3
4
  import { FlowError } from '../core/errors.js';
4
5
  import { createActionGuard } from '../integrations/call-guard.js';
5
6
  import {
@@ -165,6 +166,21 @@ function limitOf(raw, fallback, max) {
165
166
  return Math.min(n, max);
166
167
  }
167
168
 
169
+ function offsetOf(raw) {
170
+ if (raw === undefined) return 0;
171
+ const n = Number(text(raw));
172
+ if (!Number.isInteger(n) || n < 0) throw invalid('offset must be a whole number from 0');
173
+ return n;
174
+ }
175
+
176
+ // The Filter chip's view, sent as JSON (list-views.js keeps only what it knows).
177
+ function filterViewOf(raw) {
178
+ if (raw === undefined || text(raw) === '') return normaliseInvoicesView({});
179
+ let value;
180
+ try { value = JSON.parse(String(raw)); } catch { throw invalid('filter must be JSON'); }
181
+ return normaliseInvoicesView(value);
182
+ }
183
+
168
184
  // A LIKE pattern that matches the text anywhere, with its wildcards escaped.
169
185
  const containing = (q) => `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
170
186
 
@@ -404,7 +420,20 @@ export function createInvoicingRouter({
404
420
  throw invalid(`status must be one of ${TABS.join(', ')}`);
405
421
  }
406
422
  const q = text(req.query.q);
423
+ // The list pages on the server, so it works with any number of invoices:
424
+ // the tab and the search pick rows, the Filter chip's view and the sort
425
+ // (job-view.js, the page's own rules) run over all of them, and one page
426
+ // goes back with the total.
407
427
  const limit = limitOf(req.query.limit, 200, 500);
428
+ const offset = offsetOf(req.query.offset);
429
+ const view = filterViewOf(req.query.filter);
430
+ const sort = text(req.query.sort) || null;
431
+ if (sort && !INVOICE_SORTS.includes(sort)) throw invalid(`sort must be one of ${INVOICE_SORTS.join(', ')}`);
432
+ const dir = text(req.query.dir) || 'asc';
433
+ if (!['asc', 'desc'].includes(dir)) throw invalid('dir must be asc or desc');
434
+ // The person's own day, for date filters such as This week.
435
+ const day = text(req.query.today) || new Date().toISOString().slice(0, 10);
436
+ if (!isRealDate(day)) throw invalid('today must be a date (yyyy-mm-dd)');
408
437
  // Each tab switch runs this, so the independent reads run side by side.
409
438
  const system = accounting();
410
439
  const sendMode = system
@@ -420,11 +449,25 @@ export function createInvoicingRouter({
420
449
  const pattern = containing(q);
421
450
  where.push(or(ilike(invoices.number, pattern), ilike(invoices.reference, pattern), ilike(contacts.name, pattern)));
422
451
  }
423
- const rows = await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
452
+ // First the few columns the filter and sort read, for every row; then
453
+ // the whole rows of the one page.
454
+ const light = (await db.select({
455
+ id: invoices.id, number: invoices.number, reference: invoices.reference, issueDate: invoices.issueDate,
456
+ createdAt: invoices.createdAt, totalCents: invoices.totalCents, contactName: contacts.name,
457
+ }).from(invoices)
424
458
  .leftJoin(contacts, eq(contacts.id, invoices.contactId))
425
459
  .where(and(...where))
426
- .orderBy(desc(invoices.createdAt), desc(invoices.id))
427
- .limit(limit);
460
+ .orderBy(desc(invoices.createdAt), desc(invoices.id)))
461
+ .map((row) => ({ ...row, problems: tabs.problems.get(row.id) ?? [] }));
462
+ const page = invoicePage(light, { view, sort, dir, offset, limit, today: day });
463
+ const pageIds = page.rows.map((r) => r.id);
464
+ const full = pageIds.length
465
+ ? await db.select({ invoice: invoices, contactName: contacts.name }).from(invoices)
466
+ .leftJoin(contacts, eq(contacts.id, invoices.contactId))
467
+ .where(and(eq(invoices.organisationId, organisationId), inArray(invoices.id, pageIds)))
468
+ : [];
469
+ const byId = new Map(full.map((r) => [r.invoice.id, r]));
470
+ const rows = pageIds.map((id) => byId.get(id)).filter(Boolean);
428
471
  const jobs = await jobsByInvoice(db, organisationId, rows.map((r) => r.invoice.id));
429
472
  const systems = work();
430
473
  res.json({
@@ -438,6 +481,13 @@ export function createInvoicingRouter({
438
481
  problems: tabs.problems.get(invoice.id) ?? [],
439
482
  })),
440
483
  counts: Object.fromEntries(TABS.map((t) => [t, tabs.ids[t].length])),
484
+ // The page: where it starts, its size, and every row the filter keeps.
485
+ offset,
486
+ limit,
487
+ total: page.total,
488
+ totalCents: page.totalCents,
489
+ // The Filter chip's Client choices: every client of the tab.
490
+ clients: [...new Set(light.map((r) => r.contactName).filter(Boolean))].sort((a, b) => a.localeCompare(b)),
441
491
  sendsTo: system?.name ?? null,
442
492
  sendMode: mode,
443
493
  // The Linked column: one icon for each connected system.
@@ -2,7 +2,7 @@ import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
2
2
  import { people, PERSON_ROLES } from '../db/schema.js';
3
3
  import { FlowError } from '../core/errors.js';
4
4
  import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
5
- import { INVOICE_COLUMNS, normaliseJobsView, normaliseListView, RECONCILE_COLUMNS } from '../ui/job-view.js';
5
+ import { INVOICE_COLUMNS, normaliseJobsView, normaliseListView, PRICE_COLUMNS, RECONCILE_COLUMNS } from '../ui/job-view.js';
6
6
  import { normaliseSavedViews, SAVED_VIEW_PAGES } from '../ui/list-views.js';
7
7
  import { isPlainEmail } from './settings.js';
8
8
 
@@ -60,10 +60,11 @@ function viewCheck(page) {
60
60
  case 'jobs': return normaliseJobsView;
61
61
  case 'invoices': return (view) => normaliseListView(view, INVOICE_COLUMNS);
62
62
  case 'reconcile': return (view) => normaliseListView(view, RECONCILE_COLUMNS);
63
+ case 'price-list': return (view) => normaliseListView(view, PRICE_COLUMNS);
63
64
  default: return null;
64
65
  }
65
66
  }
66
- const VIEW_PAGE_NAMES = ['jobs', 'invoices', 'reconcile'];
67
+ const VIEW_PAGE_NAMES = ['jobs', 'invoices', 'reconcile', 'price-list'];
67
68
 
68
69
  // A person's saved views as Flow sends them: only the known pages, each
69
70
  // checked, so a view saved by an older Flow still loads.
package/server/ui/app.js CHANGED
@@ -142,23 +142,26 @@ function roleSelect(value, attrs = {}) {
142
142
 
143
143
  let me = null;
144
144
 
145
- // The sidebar lists the views in this order, under their group's heading.
145
+ // Where each view sits: the sidebar, in this order, daily work first (no
146
+ // `place`); the sidebar's More menu (`place: 'more'`, `more` names its Lucide
147
+ // icon); the account menu (`place: 'account'`). `sidebar: false` views open
148
+ // by link and Search only.
146
149
  const VIEWS = {
147
- jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
148
- invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
149
- reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
150
- 'invoicing-settings': { label: 'Rules', icon: 'rules', group: 'Settings', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
151
- automations: { label: 'Automations', icon: 'bolt', group: 'Settings', render: (main) => renderAutomations(main, ui()) },
152
- 'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
153
- 'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
154
- people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
155
- integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
150
+ jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
151
+ invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
152
+ reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
153
+ 'price-list': { label: 'Price list', icon: 'tag', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
154
+ 'invoicing-settings': { label: 'Rules', icon: 'rules', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
155
+ automations: { label: 'Automations', icon: 'bolt', place: 'more', more: 'zap', render: (main) => renderAutomations(main, ui()) },
156
+ 'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more', more: 'form-input', render: (main) => renderCustomFields(main, ui()) },
157
+ integrations: { label: 'Integrations', icon: 'plug', place: 'more', more: 'plug', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
158
+ people: { label: 'People', icon: 'people', place: 'account', ownerOnly: true, render: renderPeople },
156
159
  // Not in the sidebar: "Manage account" in the account menu opens the same
157
160
  // Clerk profile. The page stays for links and Search.
158
161
  // Opened from the Xero integration page: Xero invoices against Flow's own.
159
- 'xero-import': { label: 'Xero import', icon: 'plug', group: 'Settings', sidebar: false, render: (main) => renderXeroImport(main, ui()) },
160
- account: { label: 'Account', icon: 'person', group: 'Settings', sidebar: false, render: renderAccount },
161
- appearance: { label: 'Appearance', icon: 'palette', group: 'Settings', render: renderAppearance },
162
+ 'xero-import': { label: 'Xero import', icon: 'plug', sidebar: false, render: (main) => renderXeroImport(main, ui()) },
163
+ account: { label: 'Account', icon: 'person', sidebar: false, render: renderAccount },
164
+ appearance: { label: 'Appearance', icon: 'palette', place: 'account', render: renderAppearance },
162
165
  };
163
166
 
164
167
  // Line icons for the sidebar, drawn on a 24-unit grid in the text colour.
@@ -176,6 +179,7 @@ const ICON_PATHS = {
176
179
  flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
177
180
  search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
178
181
  sidebar: '<rect x="3" y="4" width="18" height="16" rx="2.5"/><path d="M9.5 4v16"/>',
182
+ more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
179
183
  palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
180
184
  };
181
185
 
@@ -238,7 +242,7 @@ document.addEventListener('keydown', (event) => {
238
242
  }
239
243
  });
240
244
  document.addEventListener('click', (event) => {
241
- if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]')) closeMenu();
245
+ if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]:not([data-nav="more"])')) closeMenu();
242
246
  });
243
247
  // Folded, a click on the sidebar's empty space opens it out again, as in
244
248
  // Claude. A click on a page, the account photo or Search still does its job.
@@ -266,15 +270,19 @@ document.addEventListener('keydown', (event) => {
266
270
 
267
271
  // "Report a problem" in Clerk's account menu, between "Manage account" and
268
272
  // "Sign out". Clerk mounts and unmounts the icon itself.
269
- function reportMenuItem(openReport) {
273
+ function accountMenuItem(label, iconName, onClick) {
270
274
  return {
271
- label: 'Report a problem',
272
- onClick: () => { openReport().catch((error) => toast(error.message, 'danger')); },
273
- mountIcon: (element) => element.replaceChildren(icon('flag')),
275
+ label,
276
+ onClick,
277
+ mountIcon: (element) => element.replaceChildren(icon(iconName)),
274
278
  unmountIcon: (element) => element.replaceChildren(),
275
279
  };
276
280
  }
277
281
 
282
+ function reportMenuItem(openReport) {
283
+ return accountMenuItem('Report a problem', 'flag', () => { openReport().catch((error) => toast(error.message, 'danger')); });
284
+ }
285
+
278
286
  // The page a person lands on after sign-in, and for / or an unknown path.
279
287
  // Invoices opens on its Ready tab. A home page can take this over later.
280
288
  const START_VIEW = 'invoices';
@@ -295,6 +303,8 @@ async function route() {
295
303
  for (const link of document.querySelectorAll('a[data-nav]')) {
296
304
  link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
297
305
  }
306
+ // More shows as current while one of its pages is open.
307
+ document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', VIEWS[view].place === 'more' ? 'page' : 'false');
298
308
  main.replaceChildren();
299
309
  main.className = 'fx-main';
300
310
  document.title = `${VIEWS[view].label} · Flow`;
@@ -352,14 +362,17 @@ function renderShell() {
352
362
  document.body.dataset.state = 'signed-in';
353
363
  const nav = $('nav');
354
364
  const items = [];
355
- let group = null;
365
+ const more = [];
356
366
  for (const [name, view] of Object.entries(VIEWS)) {
357
367
  if (view.ownerOnly && me?.person.role !== 'owner') continue;
358
- if (view.sidebar === false) continue;
359
- if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
360
- group = view.group;
361
- items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
368
+ if (view.sidebar === false || view.place === 'account') continue;
369
+ if (view.place === 'more') more.push({ icon: view.more, text: view.label, run: () => { closeMenu(); go(`/${name}`); } });
370
+ else items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
362
371
  }
372
+ // The rest of the setup pages, behind More (the shared ⋯ menu).
373
+ const [moreButton, morePanel] = rowMenu('More', more, { trigger: [icon('more'), h('span', { class: 'fx-nav__label' }, 'More')], className: 'fx-nav__more', align: 'start' });
374
+ Object.assign(moreButton.dataset, { nav: 'more', label: 'More' });
375
+ items.push(moreButton, morePanel);
363
376
  // "Report a problem" sits at the foot of the menu, above the account chip,
364
377
  // while Sentry and its report form are on (monitoring.js).
365
378
  const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
@@ -376,7 +389,11 @@ function renderShell() {
376
389
  drawMenu();
377
390
  // The sidebar foot shows who is signed in: photo or initial, then the name
378
391
  // (the email when the account has no name). It opens the account menu.
379
- const userButton = { afterSignOutUrl: '/sign-in', showName: true };
392
+ // People (owners) and Appearance live in the account menu.
393
+ const accountItems = Object.entries(VIEWS)
394
+ .filter(([, view]) => view.place === 'account' && (!view.ownerOnly || me?.person.role === 'owner'))
395
+ .map(([name, view]) => accountMenuItem(view.label, view.icon, () => go(`/${name}`)));
396
+ const userButton = { afterSignOutUrl: '/sign-in', showName: true, customMenuItems: accountItems };
380
397
  $('search').replaceChildren(icon('search'));
381
398
  $('search').onclick = search;
382
399
  mountClerk('userButton', $('user-button'), userButton);
@@ -389,7 +406,7 @@ function renderShell() {
389
406
  report.hidden = false;
390
407
  report.onclick = () => { openReport().catch((error) => toast(error.message, 'danger')); };
391
408
  unmountClerk($('user-button'));
392
- mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [reportMenuItem(openReport)] });
409
+ mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [...accountItems, reportMenuItem(openReport)] });
393
410
  })
394
411
  .catch((error) => console.warn(`[monitoring] ${error.message}`));
395
412
  window.addEventListener('popstate', route);
@@ -1,8 +1,9 @@
1
1
  // A list's columns, as ClickUp's: drag a head's right edge to make the
2
2
  // column wider or narrower (never under its kind's minimum), drag a head
3
3
  // to move the column, and click or right-click a head for its menu (Move to
4
- // start, Move to end, Hide; + shows a hidden column again). Jobs, Invoices
5
- // and Reconcile build their tables here, so every list works the same.
4
+ // start, Move to end, Hide; + shows a hidden column again). Jobs, Invoices,
5
+ // Reconcile and the Price list build their tables here, so every list works
6
+ // the same.
6
7
  //
7
8
  // The view a person keeps for a list (with their account, PUT
8
9
  // /api/me/views/:page) holds two keys from here:
@@ -25,11 +26,8 @@
25
26
  // rows the <tbody> nodes (rowCells builds a row's cells)
26
27
  // lead <th> nodes before the columns (the tick column)
27
28
  import { lucide } from '/assets/ui/custom-fields.js';
28
- import { MAX_WIDTH, minWidth, moveColumn, widthOf } from '/assets/ui/job-view.js';
29
+ import { endWidth, LEAD_WIDTH, MAX_WIDTH, minWidth, moveColumn, TAIL_WIDTH, tableWidth, widthOf } from '/assets/ui/job-view.js';
29
30
 
30
- // The tick column at the start and the + column at the end.
31
- const LEAD_WIDTH = 44;
32
- const TAIL_WIDTH = 40;
33
31
 
34
32
  // A row's cells for the shown columns: a class per kind, the label for
35
33
  // the phone layout, the full text as the cell's name on hover.
@@ -110,23 +108,42 @@ export function columnTable(h, config) {
110
108
 
111
109
  function table({ columns, rows, lead = [], className = '' }) {
112
110
  const saved = widths();
113
- // Each column keeps its own width: the table is as wide as its columns,
114
- // never stretched to the page. Space left over stays empty at the right.
115
- // A resize changes the table only; the tools stay put.
111
+ // Each column keeps its own width: never stretched to the page. As
112
+ // HubSpot's, the head and every row still run to the right edge: the
113
+ // end column (+ at its right) takes the space after the last column, so
114
+ // a row looks whole, not cut off. A resize changes the table only; the
115
+ // tools stay put.
116
116
  const cols = columns.map((c) => h('col', { style: { width: `${widthOf(c, saved)}px` } }));
117
117
  const leadCols = lead.map(() => h('col', { style: { width: `${LEAD_WIDTH}px` } }));
118
+ const endCol = h('col', { style: { width: `${TAIL_WIDTH}px` } });
118
119
  const tail = config.hidden ? h('th', { class: 'fx-th-add' }, h('button', {
119
120
  type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => openMenu(null, event),
120
121
  }, '+')) : h('th', { class: 'fx-th-end', 'aria-hidden': 'true' });
121
122
  const heads = columns.map((c, n) => head(c, n));
122
123
  const node = h('table', { class: `fx-table fx-table--sized ${className}`.trim() },
123
- h('colgroup', {}, leadCols, cols, h('col', { style: { width: `${TAIL_WIDTH}px` } })),
124
+ h('colgroup', {}, leadCols, cols, endCol),
124
125
  h('thead', {}, h('tr', {}, lead, heads, tail)),
125
126
  rows);
126
- // The table is as wide as its columns; past the page it scrolls sideways.
127
- const fit = () => { node.style.width = `${columns.reduce((sum, c) => sum + widthOf(c, widths()), 0) + lead.length * LEAD_WIDTH + TAIL_WIDTH}px`; };
127
+ const box = h('div', { class: 'fx-table-scroll' }, node);
128
+ // The width the columns need; past the window the table scrolls
129
+ // sideways. A browser shares spare width among fixed columns, so the
130
+ // end column is sized here, and again when the window changes size.
131
+ let need = tableWidth(columns, saved, lead.length);
132
+ const fit = () => {
133
+ const end = endWidth(need, box.getBoundingClientRect?.().width ?? 0);
134
+ endCol.style.width = `${end}px`;
135
+ node.style.width = `${need - TAIL_WIDTH + end}px`;
136
+ };
128
137
  fit();
129
- return h('div', { class: 'fx-table-scroll' }, node);
138
+ if (typeof ResizeObserver === 'function') {
139
+ // Once the table has left the page (a redraw), stop watching.
140
+ let shown = false;
141
+ const watch = new ResizeObserver(() => {
142
+ if (box.isConnected) { shown = true; fit(); } else if (shown) watch.disconnect();
143
+ });
144
+ watch.observe(box);
145
+ }
146
+ return box;
130
147
 
131
148
  function head(c, n) {
132
149
  const button = h('button', {
@@ -167,12 +184,13 @@ export function columnTable(h, config) {
167
184
  grip.setPointerCapture(event.pointerId);
168
185
  const from = event.clientX;
169
186
  const start = col.getBoundingClientRect().width || widthOf(c, widths());
170
- const base = node.getBoundingClientRect().width;
187
+ const base = need;
171
188
  let w = start;
172
189
  node.dataset.resizing = '';
173
190
  const move = (e) => {
174
191
  w = set(start + e.clientX - from);
175
- node.style.width = `${base + w - start}px`;
192
+ need = base + w - start;
193
+ fit();
176
194
  };
177
195
  const up = () => {
178
196
  grip.removeEventListener('pointermove', move);
@@ -141,17 +141,16 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
141
141
  .fx-toggle__mark { display: none; }
142
142
  .fx-toggle__mark .fx-mark { display: block; }
143
143
  .fx-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; overflow-y: auto; }
144
- .fx-nav__group { padding: 16px 10px 6px; color: var(--t-dim); font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body); letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case); }
145
- .fx-nav__group:first-child { padding-top: 4px; }
146
- .fx-nav a, .fx-nav__report {
144
+ .fx-nav a, .fx-nav__report, .fx-nav__more {
147
145
  display: flex; align-items: center; gap: 10px;
148
146
  padding: 8px 10px; border-radius: var(--t-radius);
149
147
  color: var(--t-text); font-weight: 500; font-size: 14px; text-decoration: none; white-space: nowrap;
150
148
  }
151
- .fx-nav a:hover, .fx-nav__report:hover { color: var(--t-strong); background: var(--fx-hover); }
152
- .fx-nav__report { margin-top: auto; border: 0; background: none; font: inherit; font-weight: 500; font-size: 14px; text-align: left; cursor: pointer; }
149
+ .fx-nav a:hover, .fx-nav__report:hover, .fx-nav__more:hover { color: var(--t-strong); background: var(--fx-hover); }
150
+ .fx-nav__report, .fx-nav__more { border: 0; background: none; font: inherit; font-weight: 500; font-size: 14px; text-align: left; cursor: pointer; }
151
+ .fx-nav__report { margin-top: auto; }
153
152
  .fx-nav__report[hidden] { display: none; }
154
- .fx-nav a[aria-current="page"] { color: var(--t-accent); background: var(--t-accent-bg); font-weight: 650; box-shadow: inset 2px 0 0 var(--t-brand); }
153
+ .fx-nav :is(a, .fx-nav__more)[aria-current="page"] { color: var(--t-accent); background: var(--t-accent-bg); font-weight: 650; box-shadow: inset 2px 0 0 var(--t-brand); }
155
154
  .fx-nav__count { margin-left: auto; color: var(--t-dim); font-size: 12px; font-weight: 700; }
156
155
  .fx-ico { display: inline-flex; flex: none; }
157
156
  .fx-ico svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
@@ -166,8 +165,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
166
165
  .fx-shell[data-folded] .fx-side :is(a, button, #user-button) { cursor: pointer; }
167
166
  .fx-shell[data-folded] .fx-side__head { flex-direction: column; gap: 10px; padding: 0 0 14px; }
168
167
  .fx-shell[data-folded] .fx-side__head .fx-brand { display: none; }
169
- .fx-shell[data-folded] .fx-nav__group { height: 1px; margin: 10px 6px; padding: 0; overflow: hidden; background: var(--fx-line); color: transparent; }
170
- .fx-shell[data-folded] :is(.fx-nav a, .fx-nav__report) { justify-content: center; padding: 9px 0; }
168
+ .fx-shell[data-folded] :is(.fx-nav a, .fx-nav__report, .fx-nav__more) { justify-content: center; padding: 9px 0; }
171
169
  .fx-shell[data-folded] .fx-nav__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
172
170
  .fx-shell[data-folded] .fx-side__end { flex-direction: column; padding: 12px 0 0; }
173
171
  .fx-shell[data-folded] #user-button { flex: none; }
@@ -730,18 +728,21 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
730
728
  .fx-table--sized[data-resizing] { cursor: col-resize; user-select: none; }
731
729
  .fx-table--sized .fx-tick-cell, .fx-table--sized .fx-th-add, .fx-table--sized .fx-c-end { overflow: visible; text-overflow: clip; }
732
730
  .fx-table--sized .fx-tick-cell { padding-right: 0; }
733
- /* + stays at the right edge while the table scrolls sideways. */
734
- .fx-table--sized .fx-th-add { position: sticky; right: 0; z-index: 1; padding-left: 0; padding-right: 12px; }
731
+ /* The end column fills the row after the last column (columns.js), as
732
+ HubSpot's; + sits at its right edge and stays there while the table
733
+ scrolls sideways. */
734
+ .fx-table--sized .fx-th-add { position: sticky; right: 0; z-index: 1; padding-left: 0; padding-right: 12px; text-align: right; }
735
735
  .fx-avatar--sm { width: 24px; height: 24px; font-size: 10px; }
736
736
  .fx-c-date time { font-variant-numeric: tabular-nums; }
737
737
  .fx-stage[data-rank="plain"] { --stage: var(--t-dim); }
738
- /* The board is one row of lanes (one per group), each about a phone wide.
739
- Lanes share the width when they fit; more lanes scroll sideways inside
740
- the board, never as a second row. On a wide, tall screen the board fills
738
+ /* The board is one row of lanes (one per group), each a fixed phone width
739
+ (--fx-lane), as HubSpot's: lanes never stretch to fill a wide screen, so
740
+ the space past the last lane stays empty. More lanes scroll sideways
741
+ inside the board, never as a second row. On a wide, tall screen the board fills
741
742
  the page down to its bottom edge, the page itself does not scroll, and
742
743
  each lane scrolls inside itself (jobs.js sets fx-main--board while the
743
744
  board shows). */
744
- .fx-board { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(min(85vw, 272px), 1fr); gap: 12px; align-items: start; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 4px; }
745
+ .fx-board { --fx-lane: min(85vw, 272px); display: grid; grid-auto-flow: column; grid-auto-columns: var(--fx-lane); justify-content: start; gap: 12px; align-items: start; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 4px; }
745
746
  .fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
746
747
  .fx-board__head { display: flex; flex: none; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 4px 2px; }
747
748
  .fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
@@ -1151,6 +1152,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1151
1152
  .fx-table.fx-table--sized { width: 100% !important; min-width: 0; table-layout: auto; }
1152
1153
  .fx-table--sized td { white-space: normal; }
1153
1154
  .fx-table--sized colgroup { display: none; }
1155
+ .fx-table--sized td.fx-c-end { display: none; }
1154
1156
  .fx-table, .fx-table tbody, .fx-table tr, .fx-table td { display: block; width: 100%; }
1155
1157
  .fx-table tr { padding: 12px 14px; border-bottom: 1px solid var(--fx-line-soft); }
1156
1158
  .fx-table td { padding: 4px 0; border: 0; }
@@ -1211,7 +1213,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1211
1213
  .fx-price__head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 8px 16px; }
1212
1214
  .fx-price__head .fx-search { margin: 0 0 6px; width: 260px; }
1213
1215
  @media (max-width: 560px) { .fx-price__head .fx-search { width: 100%; } }
1214
- .fx-price__scroll { overflow-x: auto; border: 1px solid var(--fx-line); background: var(--t-paper); }
1215
1216
  .fx-price__table th { position: sticky; top: 0; z-index: 1; }
1216
1217
  .fx-price__table td { padding-top: 9px; padding-bottom: 9px; }
1217
1218
  .fx-price__table td.fx-num, .fx-price__table th.fx-num { text-align: right; white-space: nowrap; }
@@ -1464,6 +1465,19 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1464
1465
  .fx-pager__step:hover:not(:disabled) { background: var(--fx-hover); border-color: var(--fx-line); }
1465
1466
  .fx-pager__step:disabled { opacity: 0.35; cursor: default; }
1466
1467
 
1468
+ /* The pages of a long list, in the list window's foot bar (pager.js):
1469
+ rows per page, the range, numbered pages. The page shown is filled. */
1470
+ .fx-pages { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; color: var(--t-dim); font-size: 13px; font-variant-numeric: tabular-nums; }
1471
+ .fx-pages__rows { display: inline-flex; align-items: center; gap: 6px; }
1472
+ .fx-pages__size { width: 76px; min-height: 28px; padding-block: 2px; font-size: 13px; }
1473
+ .fx-pages__range { color: var(--t-text); }
1474
+ .fx-pages__steps { display: inline-flex; align-items: center; gap: 4px; }
1475
+ .fx-pages__step, .fx-pages__num { display: inline-grid; place-items: center; min-width: 28px; height: 28px; padding: 0 6px; border: 1px solid var(--fx-line-soft); border-radius: 999px; background: transparent; color: var(--t-text); font: inherit; cursor: pointer; transition: background 120ms var(--fx-ease), border-color 120ms var(--fx-ease); }
1476
+ .fx-pages__step:hover:not(:disabled), .fx-pages__num:hover:not([aria-current]) { background: var(--fx-hover); border-color: var(--fx-line); }
1477
+ .fx-pages__step:disabled { opacity: 0.35; cursor: default; }
1478
+ .fx-pages__num[aria-current="page"] { background: var(--t-primary); border-color: var(--t-primary); color: var(--t-on-primary); cursor: default; }
1479
+ .fx-pages__gap { min-width: 16px; text-align: center; color: var(--t-mute); }
1480
+
1467
1481
  /* New invoice from a job: a searchable list of jobs with no invoice. */
1468
1482
  .fx-dialog:has(.fx-pick__body) { width: min(620px, calc(100vw - 32px)); }
1469
1483
  .fx-pick__body { display: grid; gap: 12px; }
@@ -11,8 +11,9 @@ import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
11
11
  import { caseNumber } from '/assets/ui/job-board.js';
12
12
  import { jobChangeReview } from '/assets/ui/job-change.js';
13
13
  import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
14
- import { filterJobs, INVOICE_COLUMNS, normaliseListView } from '/assets/ui/job-view.js';
15
- import { INVOICE_FILTER_FIELDS, normaliseInvoicesView } from '/assets/ui/list-views.js';
14
+ import { filterCount, filterJobs, INVOICE_COLUMNS, normaliseListView } from '/assets/ui/job-view.js';
15
+ import { INVOICE_FILTER_FIELDS, invoiceFilterKind, invoiceValue, normaliseInvoicesView } from '/assets/ui/list-views.js';
16
+ import { listPager, PAGE_SIZES } from '/assets/ui/pager.js';
16
17
  import { savedViews } from '/assets/ui/saved-views.js';
17
18
  import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
18
19
 
@@ -114,28 +115,25 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
114
115
 
115
116
  // ── The list ───────────────────────────────────────────────────────────────
116
117
 
117
- // The filter's fields: list-views.js (the server checks saved views with them).
118
+ // The filter's fields and rules: list-views.js. The server checks saved
119
+ // views with them and filters every invoice of a tab (GET /invoices).
118
120
  const FILTER_FIELDS = INVOICE_FILTER_FIELDS;
119
121
  const PROBLEM_OPS = { yes: 'Has one', no: 'Has none' };
120
122
  const FILTER_KEY = 'flow_invoice_filters';
121
- const filterKind = (field) => FILTER_FIELDS[field]?.kind ?? null;
122
-
123
- // An invoice's value for a filter field, as the filter compares it.
124
- export function invoiceValue(invoice, field) {
125
- switch (field) {
126
- case 'client': return invoice.contactName ?? null;
127
- case 'number': return invoice.number ?? null;
128
- case 'reference': return invoice.reference ?? null;
129
- case 'date': return invoice.issueDate ?? invoice.createdAt?.slice(0, 10) ?? null;
130
- case 'total': return invoice.totalCents == null ? null : invoice.totalCents / 100;
131
- case 'problem': return (invoice.problems?.length ?? 0) > 0;
132
- default: return null;
133
- }
134
- }
123
+ const SIZE_KEY = 'flow_invoice_page_size';
124
+ const filterKind = invoiceFilterKind;
125
+ export { invoiceValue };
135
126
 
136
127
  // The invoices a filter view keeps.
137
128
  export const keptInvoices = (invoices, view, now = today()) => filterJobs(invoices, view, { valueOf: invoiceValue, kindOf: filterKind, today: now });
138
129
 
130
+ // Rows per page, kept on this device.
131
+ function readSize() {
132
+ let size = null;
133
+ try { size = Number(localStorage.getItem(SIZE_KEY)); } catch { /* none kept */ }
134
+ return PAGE_SIZES.includes(size) ? size : 100;
135
+ }
136
+
139
137
  function readFilters() {
140
138
  try { return normaliseInvoicesView(JSON.parse(localStorage.getItem(FILTER_KEY))); } catch { return normaliseInvoicesView(null); }
141
139
  }
@@ -149,6 +147,8 @@ export async function renderInvoiceList(main, ui) {
149
147
  sessionStorage.removeItem(NAV_BACK);
150
148
  const state = {
151
149
  status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set(), view: readFilters(),
150
+ // The page of rows shown: the server filters and cuts it (GET /invoices).
151
+ offset: returning ? back?.offset ?? 0 : 0, limit: readSize(),
152
152
  columns: normaliseListView(ui.me?.views?.invoices, INVOICE_COLUMNS),
153
153
  };
154
154
  // The list's columns: resize, move, hide, kept with the account. A tab
@@ -171,17 +171,18 @@ export async function renderInvoiceList(main, ui) {
171
171
  label: 'Find a number, client or reference', value: state.q,
172
172
  oninput: (words) => {
173
173
  clearTimeout(timer);
174
- timer = setTimeout(() => { state.q = words.trim(); load(); }, 200);
174
+ timer = setTimeout(() => { state.q = words.trim(); state.offset = 0; load(); }, 200);
175
175
  },
176
176
  });
177
177
  // The Filter chip: the same panel as on Jobs and Reconcile (view-bar.js).
178
- // It filters the rows of the tab shown, on this device.
179
- // A change to the filter: kept in this browser, and drawn.
178
+ // The server filters every invoice of the tab shown.
179
+ // A change to the filter: kept in this browser, and the first page loads.
180
180
  function setView(patch) {
181
181
  state.view = normaliseInvoicesView({ ...state.view, ...patch });
182
182
  try { localStorage.setItem(FILTER_KEY, JSON.stringify(state.view)); } catch { /* the page still works, it forgets */ }
183
183
  views.draw();
184
- if (raw) draw(raw);
184
+ state.offset = 0;
185
+ if (raw) load();
185
186
  }
186
187
  // The person's named views (saved-views.js): their default opens on a
187
188
  // new visit.
@@ -189,12 +190,11 @@ export async function renderInvoiceList(main, ui) {
189
190
  const opening = views.opening();
190
191
  if (opening) state.view = normaliseInvoicesView(opening);
191
192
  views.draw();
192
- const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
193
193
  const filterMenu = viewBar(h).filterMenu({
194
194
  fields: () => Object.entries(FILTER_FIELDS).map(([value, f]) => ({ value, label: f.label, icon: f.icon })),
195
195
  kindOf: filterKind,
196
196
  opsOf: (field) => (field === 'problem' ? PROBLEM_OPS : null),
197
- choicesOf: (field) => (field === 'client' ? labels((raw?.invoices ?? []).map((i) => i.contactName)) : []),
197
+ choicesOf: (field) => (field === 'client' ? (raw?.clients ?? []).map((v) => ({ value: v, label: v })) : []),
198
198
  view: () => state.view,
199
199
  change: setView,
200
200
  first: 'client',
@@ -203,10 +203,22 @@ export async function renderInvoiceList(main, ui) {
203
203
  const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Filter' }, ...filterMenu.node);
204
204
  const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
205
205
  const bulk = bulkBar(ui);
206
+ // The pages, in the foot under the rows (pager.js).
207
+ const pager = listPager(h, {
208
+ label: 'Invoice pages',
209
+ change: ({ offset, limit }) => {
210
+ if (limit !== state.limit) { try { localStorage.setItem(SIZE_KEY, String(limit)); } catch { /* it forgets */ } }
211
+ Object.assign(state, { offset, limit });
212
+ state.ticked.clear();
213
+ // A new page opens at its first row.
214
+ frame.body.scrollTop = 0;
215
+ load();
216
+ },
217
+ });
206
218
  const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
207
219
  let body = null;
208
220
  let raw = null;
209
- const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
221
+ const openTab = (id) => { state.status = id; state.offset = 0; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
210
222
  const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
211
223
  // The tabs and search stay at the top while the rows scroll up under them;
212
224
  // the bulk bar floats at the foot (ClickUp's layout). Sending is a bulk
@@ -217,6 +229,8 @@ export async function renderInvoiceList(main, ui) {
217
229
  });
218
230
  const [title, ...top] = head.nodes;
219
231
  const frame = listFrame(main, host);
232
+ // The pages sit in the window's foot bar, beside the count.
233
+ frame.tools.append(pager.element);
220
234
  main.append(title, alerts.element, ...top, frame.element, bulk.element);
221
235
 
222
236
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
@@ -373,7 +387,11 @@ export async function renderInvoiceList(main, ui) {
373
387
  const seen = new Map();
374
388
  let asked = 0;
375
389
  async function load() {
376
- const query = new URLSearchParams({ status: state.status, ...(state.q ? { q: state.q } : {}) });
390
+ const query = new URLSearchParams({
391
+ status: state.status, offset: String(state.offset), limit: String(state.limit), today: today(),
392
+ ...(state.q ? { q: state.q } : {}),
393
+ ...(filterCount(state.view, filterKind) ? { filter: JSON.stringify(state.view) } : {}),
394
+ });
377
395
  const key = String(query);
378
396
  const mine = ++asked;
379
397
  if (seen.has(key)) {
@@ -414,14 +432,22 @@ export async function renderInvoiceList(main, ui) {
414
432
  async function draw(answer) {
415
433
  raw = answer;
416
434
  filterMenu.draw();
417
- body = { ...answer, invoices: keptInvoices(answer.invoices, state.view) };
435
+ body = answer;
436
+ // A page past the end (rows went to another tab): show the last one.
437
+ if (!answer.invoices.length && answer.total && answer.offset) {
438
+ state.offset = Math.max(0, Math.floor((answer.total - 1) / state.limit) * state.limit);
439
+ load();
440
+ return;
441
+ }
442
+ pager.draw({ offset: answer.offset ?? 0, limit: answer.limit ?? state.limit, total: answer.total ?? answer.invoices.length });
418
443
  const shown = new Set(body.invoices.map((i) => i.id));
419
- frame.count(plural(body.invoices.length, 'invoice', 'invoices'));
444
+ const total = answer.total ?? body.invoices.length;
445
+ frame.count(`${total.toLocaleString('en-AU')} ${total === 1 ? 'invoice' : 'invoices'}${total ? ` · ${formatCents(answer.totalCents ?? 0)}` : ''}`);
420
446
  for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
421
447
  drawTabs(body.counts);
422
448
  drawBar();
423
449
  if (!body.invoices.length) {
424
- host.replaceChildren(h('p', { class: 'fx-empty' }, answer.invoices.length
450
+ host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, filterKind)
425
451
  ? 'No invoice in this tab passes the filter.'
426
452
  : state.q
427
453
  ? 'No invoice matches that search.'
@@ -429,7 +455,7 @@ export async function renderInvoiceList(main, ui) {
429
455
  return;
430
456
  }
431
457
  // The invoice page steps through this list, in this order (navBar).
432
- saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
458
+ saveNav({ tab: state.status, q: state.q, offset: state.offset, ids: body.invoices.map((i) => i.id) });
433
459
  const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY, frameY: frame.body.scrollTop }); go(href); };
434
460
  const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
435
461
  const showProblem = state.status === 'pending';
@@ -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.
@@ -456,11 +474,14 @@ export function inOrder(columns, ids) {
456
474
  export const INVOICE_COLUMNS = Object.freeze({
457
475
  number: 'Number', client: 'Client', reference: 'Reference', date: 'Date', total: 'Total', status: 'Status', linked: 'Linked',
458
476
  });
477
+ export const PRICE_COLUMNS = Object.freeze({
478
+ code: 'Code', name: 'Name', price: 'Price', tax: 'Tax',
479
+ });
459
480
  export const RECONCILE_COLUMNS = Object.freeze({
460
481
  kind: 'Type', client: 'Client', detail: 'Detail', date: 'Date', total: 'Total', possible: 'Possible',
461
482
  });
462
483
 
463
- // A person's view of a list with set columns (Invoices, Reconcile): the
484
+ // A person's view of a list with set columns (Invoices, Reconcile, Price list): the
464
485
  // shown columns in order (every one when none is saved) and their widths.
465
486
  export function normaliseListView(input, columns) {
466
487
  const value = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
package/server/ui/kit.js CHANGED
@@ -208,9 +208,10 @@ let rowMenus = 0;
208
208
 
209
209
  // A row's ⋯ menu, as on the Custom fields page. The menu lines up with the
210
210
  // button's right edge; `align: 'start'` lines it up with its left edge.
211
- export function rowMenu(label, items, { align = 'end' } = {}) {
211
+ // `trigger` swaps the ⋯ icon for the given contents (the sidebar's More).
212
+ export function rowMenu(label, items, { trigger = null, className = 'fx-cfm__more', align = 'end' } = {}) {
212
213
  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'));
214
+ const button = h('button', { class: className, type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, trigger ?? lucide(h, 'ellipsis'));
214
215
  const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
215
216
  panel.addEventListener('toggle', (event) => {
216
217
  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
  }
@@ -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,
@@ -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', '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';