@arcforgelabs/flow 2026.10.44 → 2026.10.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +104 -2
- package/package.json +6 -2
- package/server/api/invoicing.js +53 -3
- package/server/app.js +4 -2
- package/server/auth/people.js +4 -3
- package/server/integrations/call-guard.js +21 -4
- package/server/integrations/xero/client.js +12 -2
- package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/routes.js +29 -5
- package/server/integrations/xero/runtime.js +5 -0
- package/server/integrations/xero/usage.js +74 -0
- package/server/ui/app.js +47 -27
- package/server/ui/columns.js +82 -15
- package/server/ui/flow.css +148 -37
- package/server/ui/invoicing.js +87 -38
- package/server/ui/job-view.js +33 -4
- package/server/ui/jobs.js +6 -37
- package/server/ui/kit.js +18 -11
- package/server/ui/list-views.js +44 -2
- package/server/ui/monitoring.js +51 -8
- package/server/ui/page.js +110 -5
- package/server/ui/pager.js +67 -0
- package/server/ui/price-list.js +27 -7
- package/server/ui/reconcile.js +1 -1
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +4 -4
- package/server/ui/xero-usage-model.js +106 -0
- package/server/ui/xero-usage.js +133 -0
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.
|
|
@@ -455,18 +473,29 @@ export function inOrder(columns, ids) {
|
|
|
455
473
|
// shows the invoice's problem; Number shows on the tabs with numbers only.
|
|
456
474
|
export const INVOICE_COLUMNS = Object.freeze({
|
|
457
475
|
number: 'Number', client: 'Client', reference: 'Reference', date: 'Date', total: 'Total', status: 'Status', linked: 'Linked',
|
|
476
|
+
due: 'Due', case: 'Case', practitioner: 'Practitioner', shipped: 'Shipped',
|
|
477
|
+
});
|
|
478
|
+
// The Invoices columns a person sees before they choose: the others (the
|
|
479
|
+
// invoice's due date, its job's case, practitioner and shipped date, and
|
|
480
|
+
// the lab's custom fields) wait in the + menu.
|
|
481
|
+
export const INVOICE_SHOWN = Object.freeze(['number', 'client', 'reference', 'date', 'total', 'status', 'linked']);
|
|
482
|
+
// A person's Invoices columns: custom fields ('field:<id>') are columns too.
|
|
483
|
+
export const invoiceColumnsView = (input) => normaliseListView(input, INVOICE_COLUMNS, { shown: INVOICE_SHOWN, fields: true });
|
|
484
|
+
export const PRICE_COLUMNS = Object.freeze({
|
|
485
|
+
code: 'Code', name: 'Name', price: 'Price', tax: 'Tax',
|
|
458
486
|
});
|
|
459
487
|
export const RECONCILE_COLUMNS = Object.freeze({
|
|
460
488
|
kind: 'Type', client: 'Client', detail: 'Detail', date: 'Date', total: 'Total', possible: 'Possible',
|
|
461
489
|
});
|
|
462
490
|
|
|
463
|
-
// A person's view of a list with set columns (Invoices, Reconcile): the
|
|
464
|
-
// shown columns in order (every one when none is saved) and
|
|
465
|
-
|
|
491
|
+
// A person's view of a list with set columns (Invoices, Reconcile, Price list): the
|
|
492
|
+
// shown columns in order (`shown`, else every one, when none is saved) and
|
|
493
|
+
// their widths. With `fields`, a custom field ('field:<id>') is a column too.
|
|
494
|
+
export function normaliseListView(input, columns, { shown = null, fields = false } = {}) {
|
|
466
495
|
const value = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
467
496
|
const ids = Object.keys(columns);
|
|
468
497
|
return {
|
|
469
|
-
columns: cleanColumnIds(value.columns, (id) => ids.includes(id)) ?? ids,
|
|
498
|
+
columns: cleanColumnIds(value.columns, (id) => ids.includes(id) || (fields && FIELD_GROUP.test(id))) ?? [...(shown ?? ids)],
|
|
470
499
|
widths: cleanWidths(value.widths),
|
|
471
500
|
};
|
|
472
501
|
}
|
package/server/ui/jobs.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// or with a keyboard, the job's panel has the same moves.
|
|
14
14
|
|
|
15
15
|
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
16
|
-
import { columnTable, onRightButton, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
16
|
+
import { columnTable, dateCell, fieldColumn, onRightButton, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
17
17
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
18
18
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
19
19
|
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
|
|
25
25
|
normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
|
|
26
26
|
} from '/assets/ui/job-view.js';
|
|
27
|
-
import { clipLabel
|
|
27
|
+
import { clipLabel } from '/assets/ui/person.js';
|
|
28
28
|
import { savedViews } from '/assets/ui/saved-views.js';
|
|
29
29
|
import { sidecar } from '/assets/ui/sidecar.js';
|
|
30
30
|
import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
|
|
@@ -52,18 +52,6 @@ const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4
|
|
|
52
52
|
|
|
53
53
|
const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
|
|
54
54
|
const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
|
|
55
|
-
// A list date, short: Today, Tomorrow or Yesterday, else day/month (and
|
|
56
|
-
// year when it is not this year).
|
|
57
|
-
const shortDate = (iso) => {
|
|
58
|
-
if (!iso) return '—';
|
|
59
|
-
const at = new Date(`${iso}T00:00:00`);
|
|
60
|
-
const now = new Date();
|
|
61
|
-
const days = Math.round((at - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
|
|
62
|
-
if (days === 0) return 'Today';
|
|
63
|
-
if (days === 1) return 'Tomorrow';
|
|
64
|
-
if (days === -1) return 'Yesterday';
|
|
65
|
-
return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
|
|
66
|
-
};
|
|
67
55
|
const clientOf = (job) => job.clientName ?? 'No client';
|
|
68
56
|
// The one date a card shows: when the job is due, else when it was ordered.
|
|
69
57
|
const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
|
|
@@ -136,7 +124,7 @@ export async function renderJobs(main, ui) {
|
|
|
136
124
|
// In the list, ticked rows get the same floating bar as Invoices.
|
|
137
125
|
const bulk = bulkBar(ui, { label: 'Ticked jobs' });
|
|
138
126
|
const frame = listFrame(main, host);
|
|
139
|
-
main.append(...listHead('Jobs', { views: views.element,
|
|
127
|
+
main.append(...listHead('Jobs', { views: views.element, controls, end: [find.element, toggle] }).nodes, frame.element, bulk.element);
|
|
140
128
|
|
|
141
129
|
const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
|
|
142
130
|
type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
|
|
@@ -799,7 +787,7 @@ export async function renderJobs(main, ui) {
|
|
|
799
787
|
// The list's columns, in the order the person chose them. One column holds
|
|
800
788
|
// one field; each date has its own column.
|
|
801
789
|
function listColumns() {
|
|
802
|
-
const day = (iso) => h
|
|
790
|
+
const day = (iso) => dateCell(h, iso);
|
|
803
791
|
const late = (job) => (isLate(job) ? h('span', { class: 'fx-job__date--late', title: 'Past due' }, day(job.dueOn)) : day(job.dueOn));
|
|
804
792
|
const BUILT_IN = {
|
|
805
793
|
case: { kind: 'case', className: 'fx-dim', cell: (job) => caseNumber(job) ?? '—' },
|
|
@@ -821,31 +809,12 @@ export async function renderJobs(main, ui) {
|
|
|
821
809
|
return [client, ...state.view.columns.map((id) => {
|
|
822
810
|
if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
|
|
823
811
|
const field = customColumns().find((f) => fieldGroup(f.id) === id);
|
|
824
|
-
|
|
825
|
-
const cell = field.type === 'person' ? (job) => personBadge(job, field)
|
|
826
|
-
: field.type === 'date' ? (job) => day(valueOf(job, field.id)?.value ?? null) : (job) => fieldText(job, field);
|
|
827
|
-
return { id, label: field.name, kind: field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text', num: field.type === 'number', className: 'fx-dim', cell };
|
|
812
|
+
return field ? fieldColumn(h, field, { id, valueOf: (job) => valueOf(job, field.id)?.value ?? null, people: state.people }) : null;
|
|
828
813
|
}).filter(Boolean).map((c) => ({ ...c, sort: () => sortButton(c.id, c.label) }))];
|
|
829
814
|
}
|
|
830
815
|
|
|
831
816
|
// A person custom field's value as a badge (initials), the name on hover.
|
|
832
|
-
|
|
833
|
-
const value = field ? valueOf(job, field.id)?.value ?? null : null;
|
|
834
|
-
if (!value) return h('span', { class: 'fx-dim' }, '—');
|
|
835
|
-
const name = state.people.find((p) => p.id === value)?.name ?? 'Former person';
|
|
836
|
-
return h('span', { class: 'fx-avatar fx-avatar--sm', role: 'img', 'aria-label': name, title: name }, initialsOf(name));
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
// A job's custom field value as text.
|
|
840
|
-
function fieldText(job, field) {
|
|
841
|
-
const value = field ? valueOf(job, field.id)?.value ?? null : null;
|
|
842
|
-
if (value == null || value === false) return '—';
|
|
843
|
-
if (field.type === 'checkbox') return 'Yes';
|
|
844
|
-
if (field.type === 'person') return state.people.find((p) => p.id === value)?.name ?? 'Former person';
|
|
845
|
-
if (field.type === 'dropdown') return field.options?.find((o) => o.id === value)?.name ?? '—';
|
|
846
|
-
if (field.type === 'date') return showDate(value);
|
|
847
|
-
return String(value);
|
|
848
|
-
}
|
|
817
|
+
const personBadge = (job, field) => personCell(h, field ? valueOf(job, field.id)?.value ?? null : null, state.people);
|
|
849
818
|
|
|
850
819
|
// The list: one block per group, with the group's label over its rows.
|
|
851
820
|
// A tick column at the left; a group's head has a tick for every job in
|
package/server/ui/kit.js
CHANGED
|
@@ -63,22 +63,28 @@ export function pageHeader(title, { count = null, help = null, actions = [] } =
|
|
|
63
63
|
|
|
64
64
|
// The top of a list page (Jobs, Invoices, Reconcile), laid out the same on
|
|
65
65
|
// every list and in Board and List alike, so nothing moves when a person
|
|
66
|
-
// switches: the title row,
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
66
|
+
// switches: the title row, then one toolbar with `start` at the left (tabs,
|
|
67
|
+
// Group, Sort, Filter) and `end` at the right (search, Board/List), then the
|
|
68
|
+
// page's `actions` (New invoice) last, as Claude's own lists do. `controls`
|
|
69
|
+
// (the page's Group, Sort and Filter) sit with the saved view tabs at their
|
|
70
|
+
// right (`views`: the page's savedViews element, else an empty slot that
|
|
71
|
+
// stays hidden), at the end of `start`, or of `end` when `controlsAt` is
|
|
72
|
+
// 'end'. `line` draws the rule that tabs sit on; `sticky` keeps the toolbar
|
|
73
|
+
// at the top while the rows scroll up under it, with a shadow once they do.
|
|
74
|
+
// Append `nodes` to the page.
|
|
75
|
+
export function listHead(title, { count = null, help = null, actions = [], start = [], end = [], controls = null, controlsAt = 'start', line = false, sticky = false, views: tabs = null } = {}) {
|
|
73
76
|
const views = tabs ?? h('div', { class: 'fx-views', role: 'group', 'aria-label': 'Views', hidden: true });
|
|
77
|
+
const tools = h('div', { class: 'fx-toolbar__tools' }, controls, views);
|
|
78
|
+
const left = controlsAt === 'end' ? [start].flat() : [start, tools].flat();
|
|
79
|
+
const right = controlsAt === 'end' ? [tools, end].flat() : [end].flat();
|
|
74
80
|
const bar = h('div', { class: 'fx-listhead', 'data-sticky': sticky },
|
|
75
|
-
h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` },
|
|
81
|
+
h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, left, h('div', { class: 'fx-toolbar__end' }, right, actions)));
|
|
76
82
|
const edge = sticky ? h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' }) : null;
|
|
77
83
|
if (edge && typeof IntersectionObserver === 'function') {
|
|
78
84
|
const top = window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1;
|
|
79
85
|
new IntersectionObserver(([entry]) => bar.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${top}px 0px 0px 0px` }).observe(edge);
|
|
80
86
|
}
|
|
81
|
-
return { nodes: [pageHeader(title, { count, help }),
|
|
87
|
+
return { nodes: [pageHeader(title, { count, help }), edge, bar].filter(Boolean), views, bar };
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
// The window a list or board sits in under listHead, as HubSpot's: on a
|
|
@@ -208,9 +214,10 @@ let rowMenus = 0;
|
|
|
208
214
|
|
|
209
215
|
// A row's ⋯ menu, as on the Custom fields page. The menu lines up with the
|
|
210
216
|
// button's right edge; `align: 'start'` lines it up with its left edge.
|
|
211
|
-
|
|
217
|
+
// `trigger` swaps the ⋯ icon for the given contents (the sidebar's More).
|
|
218
|
+
export function rowMenu(label, items, { trigger = null, className = 'fx-cfm__more', align = 'end' } = {}) {
|
|
212
219
|
const id = `fx-row-menu-${++rowMenus}`;
|
|
213
|
-
const button = h('button', { class:
|
|
220
|
+
const button = h('button', { class: className, type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, trigger ?? lucide(h, 'ellipsis'));
|
|
214
221
|
const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
|
|
215
222
|
panel.addEventListener('toggle', (event) => {
|
|
216
223
|
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
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
|
}
|
package/server/ui/page.js
CHANGED
|
@@ -71,20 +71,125 @@ ${body}
|
|
|
71
71
|
`;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
// Mode labels on the connected callback card (issue #211). Sandbox is the
|
|
75
|
+
// Demo Company; replay is for development and CI.
|
|
76
|
+
const MODE_LABELS = Object.freeze({ live: 'Live', sandbox: 'Demo', replay: 'Replay' });
|
|
77
|
+
|
|
78
|
+
function modeLabel(mode) {
|
|
79
|
+
if (mode == null || mode === '') return '';
|
|
80
|
+
const key = String(mode).toLowerCase();
|
|
81
|
+
return MODE_LABELS[key] ?? String(mode).replace(/^./, (c) => c.toUpperCase());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function lucideIcon(name) {
|
|
85
|
+
return `<span class="fx-lucide" style="--icon: url(/assets/icons/${escapeHtml(name)}.svg)" aria-hidden="true"></span>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A one-message page, such as the end of connecting an integration.
|
|
90
|
+
*
|
|
91
|
+
* Optional `integration` ({ name, logo }) shows Flow's mark beside the
|
|
92
|
+
* maker's logo. Optional `organisation` and `mode` become a status line.
|
|
93
|
+
* On success, `autoReturnSeconds` (default 5 when tone is success and an
|
|
94
|
+
* integration is named) starts a polite countdown and returns to Flow; set
|
|
95
|
+
* it to 0 or false to keep the person on the page. Failure never auto-returns.
|
|
96
|
+
*/
|
|
97
|
+
export function messagePage({
|
|
98
|
+
title,
|
|
99
|
+
text,
|
|
100
|
+
defaultFamily,
|
|
101
|
+
tone = 'info',
|
|
102
|
+
integration = null,
|
|
103
|
+
organisation = null,
|
|
104
|
+
mode = null,
|
|
105
|
+
autoReturnSeconds,
|
|
106
|
+
href = '/',
|
|
107
|
+
}) {
|
|
108
|
+
const safeHref = typeof href === 'string' && href.startsWith('/') && !href.startsWith('//') ? href : '/';
|
|
109
|
+
const logo = integration?.logo && /^[a-z0-9][a-z0-9._-]*\.svg$/i.test(integration.logo) ? integration.logo : null;
|
|
110
|
+
const integrationName = integration?.name ? String(integration.name) : '';
|
|
111
|
+
const showMarks = Boolean(logo || integrationName);
|
|
112
|
+
const resultIcon = tone === 'success' ? 'check-circle' : tone === 'danger' ? 'x-circle' : null;
|
|
113
|
+
const org = organisation != null && String(organisation).trim() ? String(organisation).trim() : '';
|
|
114
|
+
const modeText = modeLabel(mode);
|
|
115
|
+
const statusParts = [org, modeText].filter(Boolean);
|
|
116
|
+
const returnSeconds = autoReturnSeconds === false || autoReturnSeconds === 0
|
|
117
|
+
? 0
|
|
118
|
+
: tone === 'success' && showMarks
|
|
119
|
+
? Math.max(1, Math.min(30, Number(autoReturnSeconds) || 5))
|
|
120
|
+
: 0;
|
|
121
|
+
|
|
122
|
+
const marks = showMarks
|
|
123
|
+
? `<div class="fx-message__marks" aria-hidden="true">
|
|
124
|
+
<span class="fx-logo fx-logo--lg fx-message__flow">${FLOW_MARK}</span>
|
|
125
|
+
<span class="fx-message__plus">+</span>
|
|
126
|
+
<span class="fx-logo fx-logo--lg">${logo
|
|
127
|
+
? `<img src="/assets/ui/logos/${escapeHtml(logo)}" alt="">`
|
|
128
|
+
: lucideIcon('plug')}</span>
|
|
129
|
+
${resultIcon ? `<span class="fx-message__result" data-tone="${escapeHtml(tone)}">${lucideIcon(resultIcon)}</span>` : ''}
|
|
130
|
+
</div>`
|
|
131
|
+
: resultIcon
|
|
132
|
+
? `<div class="fx-message__marks fx-message__marks--solo" aria-hidden="true">
|
|
133
|
+
<span class="fx-message__result" data-tone="${escapeHtml(tone)}">${lucideIcon(resultIcon)}</span>
|
|
134
|
+
</div>`
|
|
135
|
+
: '';
|
|
136
|
+
|
|
137
|
+
const status = statusParts.length
|
|
138
|
+
? `<p class="fx-message__status"><span class="fx-status" data-tone="${escapeHtml(tone === 'danger' ? 'danger' : 'success')}">${escapeHtml(statusParts.join(' · '))}</span></p>`
|
|
139
|
+
: '';
|
|
140
|
+
|
|
141
|
+
const returnLine = returnSeconds
|
|
142
|
+
? `<p class="fx-message__return" hidden aria-live="polite" data-return-after="${returnSeconds}" data-return-href="${escapeHtml(safeHref)}"></p>`
|
|
143
|
+
: '';
|
|
144
|
+
|
|
145
|
+
// Clean the OAuth query from the address. On success only, count down and
|
|
146
|
+
// return to Flow. Without JavaScript the Back button is enough.
|
|
147
|
+
const cleanScript = showMarks || returnSeconds
|
|
148
|
+
? `<script>try{history.replaceState(null,'',location.pathname)}catch(e){}</script>`
|
|
149
|
+
: '';
|
|
150
|
+
const returnScript = returnSeconds
|
|
151
|
+
? `<script>
|
|
152
|
+
(function () {
|
|
153
|
+
var el = document.querySelector('[data-return-after]');
|
|
154
|
+
if (!el) return;
|
|
155
|
+
var left = Number(el.getAttribute('data-return-after')) || 0;
|
|
156
|
+
var href = el.getAttribute('data-return-href') || '/';
|
|
157
|
+
if (left < 1) return;
|
|
158
|
+
el.hidden = false;
|
|
159
|
+
function say(n) {
|
|
160
|
+
el.textContent = n === 1 ? 'Going back to Flow in 1 second' : 'Going back to Flow in ' + n + ' seconds';
|
|
161
|
+
}
|
|
162
|
+
say(left);
|
|
163
|
+
var timer = setInterval(function () {
|
|
164
|
+
left -= 1;
|
|
165
|
+
if (left <= 0) {
|
|
166
|
+
clearInterval(timer);
|
|
167
|
+
location.assign(href);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
say(left);
|
|
171
|
+
}, 1000);
|
|
172
|
+
})();
|
|
173
|
+
</script>`
|
|
174
|
+
: '';
|
|
175
|
+
|
|
76
176
|
return renderPage({
|
|
77
177
|
title,
|
|
78
178
|
defaultFamily,
|
|
79
179
|
bodyClass: 'fx-centre',
|
|
80
180
|
scripts: ['/assets/ui/appearance.js'],
|
|
81
181
|
body: `<main class="fx-message">
|
|
82
|
-
<a class="fx-brand" href="
|
|
182
|
+
<a class="fx-brand" href="${escapeHtml(safeHref)}">${FLOW_MARK}<span>Flow</span></a>
|
|
83
183
|
<section class="fx-card fx-message__card" data-tone="${escapeHtml(tone)}">
|
|
184
|
+
${marks}
|
|
84
185
|
<h1 class="fx-title">${escapeHtml(title)}</h1>
|
|
186
|
+
${status}
|
|
85
187
|
<p>${escapeHtml(text)}</p>
|
|
86
|
-
|
|
188
|
+
${returnLine}
|
|
189
|
+
<a class="fx-btn" href="${escapeHtml(safeHref)}">Back to Flow</a>
|
|
87
190
|
</section>
|
|
88
|
-
</main
|
|
191
|
+
</main>
|
|
192
|
+
${cleanScript}
|
|
193
|
+
${returnScript}`,
|
|
89
194
|
});
|
|
90
195
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The pages of a long list, in the list window's foot bar (listFrame), as
|
|
2
|
+
// HubSpot's: rows per page, "1–100 of 1,204", and numbered pages between
|
|
3
|
+
// previous and next. The server cuts the page (GET /api/invoicing/invoices
|
|
4
|
+
// takes offset and limit), so a list works with any number of rows.
|
|
5
|
+
//
|
|
6
|
+
// listPager(h, { label, sizes, change }) answers { element, draw(page) }.
|
|
7
|
+
// page = { offset, limit, total }. change({ offset, limit }) asks for a
|
|
8
|
+
// page; a new size starts again at the first page. One page or none hides
|
|
9
|
+
// the page numbers; the size stays, so a person can show more per page.
|
|
10
|
+
// Issue #220 brings the pager over one record (.fx-pager) into this file.
|
|
11
|
+
|
|
12
|
+
export const PAGE_SIZES = Object.freeze([25, 50, 100, 200]);
|
|
13
|
+
|
|
14
|
+
const chevron = (d) => `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="${d}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
15
|
+
const PREV = chevron('M14.5 6l-6 6 6 6');
|
|
16
|
+
const NEXT = chevron('M9.5 6l6 6-6 6');
|
|
17
|
+
const count = new Intl.NumberFormat('en-AU');
|
|
18
|
+
|
|
19
|
+
// The page numbers to show: the first, the last, and two each side of the
|
|
20
|
+
// one shown, with '…' for a gap. Pages count from 1.
|
|
21
|
+
export function pageNumbers(at, pages) {
|
|
22
|
+
if (pages <= 7) return Array.from({ length: pages }, (_, n) => n + 1);
|
|
23
|
+
const near = new Set([1, pages, at - 1, at, at + 1]);
|
|
24
|
+
if (at <= 3) [2, 3, 4].forEach((n) => near.add(n));
|
|
25
|
+
if (at >= pages - 2) [pages - 3, pages - 2, pages - 1].forEach((n) => near.add(n));
|
|
26
|
+
const list = [...near].filter((n) => n >= 1 && n <= pages).sort((a, b) => a - b);
|
|
27
|
+
return list.flatMap((n, i) => (i && n - list[i - 1] > 1 ? ['…', n] : [n]));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// "1–100 of 1,204", or "0 of 0".
|
|
31
|
+
export function pageRange({ offset, limit, total }) {
|
|
32
|
+
if (!total) return '0 of 0';
|
|
33
|
+
return `${count.format(offset + 1)}–${count.format(Math.min(offset + limit, total))} of ${count.format(total)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function listPager(h, { label = 'Pages', sizes = PAGE_SIZES, change }) {
|
|
37
|
+
const element = h('nav', { class: 'fx-pages', 'aria-label': label });
|
|
38
|
+
const icon = (markup) => { const span = h('span', { 'aria-hidden': 'true' }); span.innerHTML = markup; return span; };
|
|
39
|
+
|
|
40
|
+
function draw({ offset = 0, limit = sizes[0], total = 0 }) {
|
|
41
|
+
const pages = Math.max(1, Math.ceil(total / limit));
|
|
42
|
+
const at = Math.min(pages, Math.floor(offset / limit) + 1);
|
|
43
|
+
const go = (n) => change({ offset: (n - 1) * limit, limit });
|
|
44
|
+
const size = h('select', {
|
|
45
|
+
class: 'fx-select fx-pages__size', 'aria-label': 'Rows per page', title: 'Rows per page',
|
|
46
|
+
onchange: (event) => change({ offset: 0, limit: Number(event.target.value) }),
|
|
47
|
+
}, sizes.map((n) => h('option', { value: String(n), ...(n === limit ? { selected: true } : {}) }, String(n))));
|
|
48
|
+
const step = (n, markup, name) => h('button', {
|
|
49
|
+
type: 'button', class: 'fx-pages__step', 'aria-label': name, title: name,
|
|
50
|
+
...(n < 1 || n > pages ? { disabled: true } : {}), onclick: () => go(n),
|
|
51
|
+
}, icon(markup));
|
|
52
|
+
element.replaceChildren(
|
|
53
|
+
h('label', { class: 'fx-pages__rows' }, 'Rows', size),
|
|
54
|
+
h('span', { class: 'fx-pages__range' }, pageRange({ offset, limit, total })),
|
|
55
|
+
pages > 1 ? h('span', { class: 'fx-pages__steps' },
|
|
56
|
+
step(at - 1, PREV, 'Previous page'),
|
|
57
|
+
pageNumbers(at, pages).map((n) => (n === '…'
|
|
58
|
+
? h('span', { class: 'fx-pages__gap', 'aria-hidden': 'true' }, '…')
|
|
59
|
+
: h('button', {
|
|
60
|
+
type: 'button', class: 'fx-pages__num', 'aria-label': `Page ${n}`,
|
|
61
|
+
...(n === at ? { 'aria-current': 'page' } : {}), onclick: () => { if (n !== at) go(n); },
|
|
62
|
+
}, String(n)))),
|
|
63
|
+
step(at + 1, NEXT, 'Next page')) : '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { element, draw };
|
|
67
|
+
}
|
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/reconcile.js
CHANGED
|
@@ -204,7 +204,7 @@ async function renderList(main, ui) {
|
|
|
204
204
|
const host = h('div', { class: 'fx-rec' }, h('div', { class: 'fx-skeleton' }));
|
|
205
205
|
const bulk = bulkBar(ui, { label: 'Ticked jobs' });
|
|
206
206
|
const frame = listFrame(main, host);
|
|
207
|
-
main.append(...listHead('Reconcile', { views: views.element,
|
|
207
|
+
main.append(...listHead('Reconcile', { views: views.element, controls, end: find.element }).nodes, frame.element, bulk.element);
|
|
208
208
|
|
|
209
209
|
// The Filter chip: the same panel as on Jobs (view-bar.js). Job rows also
|
|
210
210
|
// have the lab's custom fields.
|
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', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'person.js', 'pager.js', 'flow-mark.svg']);
|
|
25
25
|
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
26
26
|
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
27
27
|
const MAX_AGE = '1h';
|