@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.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +53 -3
- package/server/auth/people.js +3 -2
- package/server/ui/app.js +42 -25
- package/server/ui/columns.js +33 -15
- package/server/ui/flow.css +29 -15
- package/server/ui/invoicing.js +55 -29
- package/server/ui/job-view.js +22 -1
- package/server/ui/kit.js +3 -2
- package/server/ui/list-views.js +44 -2
- package/server/ui/monitoring.js +51 -8
- package/server/ui/pager.js +67 -0
- package/server/ui/price-list.js +27 -7
- package/server/ui/routes.js +1 -1
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
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.
|
|
9
|
+
"version": "2026.10.45",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/api/invoicing.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
.
|
|
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.
|
package/server/auth/people.js
CHANGED
|
@@ -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
|
-
//
|
|
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',
|
|
148
|
-
invoices: { label: 'Invoices', icon: 'invoices',
|
|
149
|
-
reconcile: { label: 'Reconcile', icon: 'reconcile',
|
|
150
|
-
'
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
'custom-fields': { label: 'Custom fields', icon: 'fields',
|
|
154
|
-
|
|
155
|
-
|
|
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',
|
|
160
|
-
account: { label: 'Account', icon: 'person',
|
|
161
|
-
appearance: { label: 'Appearance', icon: 'palette',
|
|
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
|
|
273
|
+
function accountMenuItem(label, iconName, onClick) {
|
|
270
274
|
return {
|
|
271
|
-
label
|
|
272
|
-
onClick
|
|
273
|
-
mountIcon: (element) => element.replaceChildren(icon(
|
|
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
|
-
|
|
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.
|
|
360
|
-
|
|
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
|
-
|
|
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);
|
package/server/ui/columns.js
CHANGED
|
@@ -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
|
|
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:
|
|
114
|
-
//
|
|
115
|
-
//
|
|
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,
|
|
124
|
+
h('colgroup', {}, leadCols, cols, endCol),
|
|
124
125
|
h('thead', {}, h('tr', {}, lead, heads, tail)),
|
|
125
126
|
rows);
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
192
|
+
need = base + w - start;
|
|
193
|
+
fit();
|
|
176
194
|
};
|
|
177
195
|
const up = () => {
|
|
178
196
|
grip.removeEventListener('pointermove', move);
|
package/server/ui/flow.css
CHANGED
|
@@ -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-
|
|
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
|
|
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-
|
|
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
|
-
/*
|
|
734
|
-
|
|
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
|
|
739
|
-
|
|
740
|
-
the
|
|
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:
|
|
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; }
|
package/server/ui/invoicing.js
CHANGED
|
@@ -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
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
179
|
-
// A change to the filter: kept in this browser, and
|
|
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
|
-
|
|
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' ?
|
|
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({
|
|
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 =
|
|
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
|
-
|
|
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' },
|
|
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';
|
package/server/ui/job-view.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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'));
|
package/server/ui/list-views.js
CHANGED
|
@@ -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.
|
package/server/ui/monitoring.js
CHANGED
|
@@ -17,15 +17,46 @@ function loadScript({ src, integrity }) {
|
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// "
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/server/ui/price-list.js
CHANGED
|
@@ -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
|
|
165
|
-
h('td', {}
|
|
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-
|
|
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,
|
package/server/ui/routes.js
CHANGED
|
@@ -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';
|