@arcforgelabs/flow 2026.10.51 → 2026.10.52
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/captures.js +26 -4
- package/server/api/invoicing.js +1 -1
- package/server/app.js +2 -0
- package/server/auth/people.js +3 -3
- package/server/core/ai.js +2 -1
- package/server/core/capture-match.js +158 -0
- package/server/core/captures.js +111 -7
- package/server/core/invoice-rules.js +16 -5
- package/server/core/reader.js +1 -10
- package/server/core/sending.js +7 -3
- package/server/db/migrations/0024_rule_icon.sql +5 -0
- package/server/db/migrations/meta/0024_snapshot.json +4703 -0
- package/server/db/migrations/meta/_journal.json +8 -1
- package/server/db/schema.js +3 -0
- package/server/integrations/seazona/history/backfill.js +39 -9
- package/server/integrations/seazona/index.js +1 -12
- package/server/integrations/seazona/stage-moves.js +3 -13
- package/server/integrations/sentry/index.js +6 -6
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/drafts.js +27 -13
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +61 -20
- package/server/ui/capture-fields.js +17 -0
- package/server/ui/captures.js +283 -0
- package/server/ui/columns.js +68 -3
- package/server/ui/flow.css +92 -17
- package/server/ui/invoice-rules.js +10 -2
- package/server/ui/invoicing-settings.js +2 -8
- package/server/ui/invoicing.js +12 -8
- package/server/ui/job-view.js +6 -3
- package/server/ui/jobs.js +40 -117
- package/server/ui/kit.js +86 -1
- package/server/ui/list-views.js +47 -3
- package/server/ui/mobile.js +45 -5
- package/server/ui/nav-pins.js +4 -2
- package/server/ui/price-list.js +138 -69
- package/server/ui/routes.js +4 -1
- package/server/ui/saved-views.js +2 -0
- package/server/ui/view-bar.js +61 -2
package/server/ui/nav-pins.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
export const NAV_PAGES = Object.freeze([
|
|
7
7
|
'jobs',
|
|
8
|
+
'captures',
|
|
8
9
|
'invoices',
|
|
9
10
|
'contacts',
|
|
10
11
|
'reconcile',
|
|
@@ -17,11 +18,12 @@ export const NAV_PAGES = Object.freeze([
|
|
|
17
18
|
|
|
18
19
|
export const NAV_LOCKED = Object.freeze(['jobs', 'invoices']);
|
|
19
20
|
|
|
20
|
-
// Today's sidebar, before a person has chosen: daily work (Jobs,
|
|
21
|
-
// Contacts, Reconcile), Price list and Rules. Automations, Custom fields
|
|
21
|
+
// Today's sidebar, before a person has chosen: daily work (Jobs, Capture,
|
|
22
|
+
// Invoices, Contacts, Reconcile), Price list and Rules. Automations, Custom fields
|
|
22
23
|
// and Integrations start in More.
|
|
23
24
|
export const NAV_DEFAULT = Object.freeze([
|
|
24
25
|
'jobs',
|
|
26
|
+
'captures',
|
|
25
27
|
'invoices',
|
|
26
28
|
'contacts',
|
|
27
29
|
'reconcile',
|
package/server/ui/price-list.js
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
1
|
+
// Price list (/price-list; server/core/price-list.js, docs/PRICE_LIST.md).
|
|
2
|
+
// Flow's own codes and prices, which work with no integration connected.
|
|
3
|
+
// Laid out like Jobs and Invoices: the shared list head (Active / Archived
|
|
4
|
+
// tabs, Group, Sort, Filter, saved views, Find) over the list window, one
|
|
5
|
+
// row per code. A row opens a dialog with the code's fields, its links and,
|
|
6
|
+
// when something differs, Mark resolved. The head's icons open Reconcile
|
|
7
|
+
// (price-reconcile.js, while an integration with its own price list is on)
|
|
8
|
+
// and the price list's settings. Everyone can read it; an owner or admin
|
|
9
|
+
// changes it. The caller passes in app.js's shared pieces as `ui`.
|
|
9
10
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
10
|
-
import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
11
|
-
import {
|
|
11
|
+
import { columnTable, groupHead, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
12
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
13
|
+
import { filterJobs, groupJobs, PRICE_COLUMNS, sortJobs } from '/assets/ui/job-view.js';
|
|
14
|
+
import { normalisePriceView, PRICE_FIELDS, priceValue } from '/assets/ui/list-views.js';
|
|
12
15
|
import { reconcileRows } from '/assets/ui/price-match.js';
|
|
16
|
+
import { savedViews } from '/assets/ui/saved-views.js';
|
|
17
|
+
import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
|
|
13
18
|
|
|
14
|
-
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/PRICE_LIST.md';
|
|
15
19
|
const TAX_LABELS = { GST: 'GST', GST_FREE: 'GST free' };
|
|
20
|
+
// Where an invoice line takes its price (the line mapping routine's
|
|
21
|
+
// `linePrice`): the job's own amount, or this list's price.
|
|
16
22
|
const LINE_PRICES = [
|
|
17
|
-
{ value: 'work', label: '
|
|
23
|
+
{ value: 'work', label: 'Job price' },
|
|
18
24
|
{ value: 'list', label: 'Price list' },
|
|
19
25
|
];
|
|
26
|
+
const NUMBER_WORDS = ['Low to high', 'High to low'];
|
|
20
27
|
|
|
21
28
|
// The list's columns (columns.js): each keeps its own width, saved with the
|
|
22
29
|
// person's account; + shows a hidden one again.
|
|
23
30
|
const COLUMN_DEFS = {
|
|
24
|
-
code: { kind: 'number', className: 'fx-price__code', cell: (item) => item.code },
|
|
31
|
+
code: { kind: 'number', phone: 'meta', className: 'fx-price__code', cell: (item) => item.code },
|
|
25
32
|
name: { kind: 'client', phone: 'title', cell: (item) => item.name },
|
|
26
33
|
price: { kind: 'total', num: true, cell: (item) => money(item.unitAmountCents) },
|
|
27
34
|
tax: { kind: 'case', className: 'fx-dim', cell: (item) => TAX_LABELS[item.taxCode] ?? item.taxCode },
|
|
@@ -34,31 +41,79 @@ const toCents = (text) => {
|
|
|
34
41
|
};
|
|
35
42
|
|
|
36
43
|
export async function renderPriceList(main, ui) {
|
|
37
|
-
const { h, flow, toast, notice, dialog,
|
|
44
|
+
const { h, flow, toast, notice, dialog, listHead, listFrame, me } = ui;
|
|
38
45
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
39
|
-
const host = h('div', { class: 'fx-
|
|
40
|
-
|
|
46
|
+
const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
47
|
+
// The person's view (list-views.js): columns, Group, Sort and Filter,
|
|
48
|
+
// kept with their account.
|
|
49
|
+
let view = normalisePriceView(me?.views?.['price-list']);
|
|
41
50
|
const saveView = viewSaver(ui, 'price-list');
|
|
51
|
+
function change(patch) {
|
|
52
|
+
view = normalisePriceView({ ...view, ...patch });
|
|
53
|
+
saveView(view);
|
|
54
|
+
views.draw();
|
|
55
|
+
if (data) draw();
|
|
56
|
+
}
|
|
57
|
+
const views = savedViews(ui, { page: 'price-list', current: () => view, apply: change });
|
|
58
|
+
const opening = views.opening();
|
|
59
|
+
if (opening) view = normalisePriceView(opening);
|
|
60
|
+
views.draw();
|
|
61
|
+
|
|
62
|
+
let data = null;
|
|
63
|
+
let query = '';
|
|
64
|
+
let tab = 'active';
|
|
65
|
+
let opened = new URLSearchParams(location.search).get('code');
|
|
66
|
+
const systemName = (id) => data?.systems.find((s) => s.id === id)?.name ?? id;
|
|
67
|
+
const taxName = (code) => TAX_LABELS[code] ?? code;
|
|
68
|
+
|
|
69
|
+
// Group, Sort and Filter: the same chips as on Jobs (view-bar.js).
|
|
70
|
+
const kindOf = (field) => PRICE_FIELDS[field]?.kind ?? null;
|
|
71
|
+
const fieldList = (keep = () => true) => Object.entries(PRICE_FIELDS).filter(([, f]) => keep(f)).map(([value, f]) => ({ value, label: f.label, icon: f.icon }));
|
|
72
|
+
const bar = viewBar(h);
|
|
73
|
+
const groupMenu = bar.groupMenu({ options: () => fieldList((f) => f.group), view: () => view, change });
|
|
74
|
+
const sortMenu = bar.sortMenu({
|
|
75
|
+
options: fieldList, view: () => view, change,
|
|
76
|
+
dirWords: (by) => (kindOf(by) === 'number' ? NUMBER_WORDS : ['A to Z', 'Z to A']),
|
|
77
|
+
nameOf: (by) => PRICE_FIELDS[by].label, empty: 'No sort. Codes show A to Z.',
|
|
78
|
+
});
|
|
79
|
+
const filterMenu = bar.filterMenu({
|
|
80
|
+
fields: fieldList, kindOf,
|
|
81
|
+
choicesOf: (field) => (field === 'tax' ? Object.entries(TAX_LABELS).map(([value, label]) => ({ value, label }))
|
|
82
|
+
: field === 'linked' ? (data?.systems ?? []).map((s) => ({ value: s.id, label: s.name })) : []),
|
|
83
|
+
view: () => view, change, first: 'tax', empty: 'No filters. Every code shows.',
|
|
84
|
+
});
|
|
85
|
+
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' }, ...groupMenu.node, ...sortMenu.node, ...filterMenu.node);
|
|
86
|
+
const drawControls = () => {
|
|
87
|
+
groupMenu.set(view.group === 'none' ? 'Group' : `Group: ${PRICE_FIELDS[view.group].label}`, view.group !== 'none', view.group === 'none' ? null : () => change({ group: 'none' }));
|
|
88
|
+
sortMenu.draw();
|
|
89
|
+
filterMenu.draw();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// The list's columns (columns.js): each keeps its own width, saved with
|
|
93
|
+
// the person's account; + shows a hidden one again; a head sorts.
|
|
42
94
|
const heads = columnTable(h, {
|
|
43
95
|
order: () => view.columns,
|
|
44
96
|
widths: () => view.widths,
|
|
45
|
-
change
|
|
46
|
-
view = normaliseListView({ ...view, ...patch }, PRICE_COLUMNS);
|
|
47
|
-
saveView(view);
|
|
48
|
-
draw();
|
|
49
|
-
},
|
|
97
|
+
change,
|
|
50
98
|
hidden: () => Object.keys(PRICE_COLUMNS).filter((id) => !view.columns.includes(id)).map((id) => ({ id, label: PRICE_COLUMNS[id] })),
|
|
99
|
+
sorting: { sortOf: (id) => (PRICE_FIELDS[id] ? id : null), sorts: () => view.sorts, change, dirWords: (by) => (kindOf(by) === 'number' ? NUMBER_WORDS : ['A to Z', 'Z to A']) },
|
|
51
100
|
});
|
|
52
|
-
const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Add code');
|
|
53
|
-
const reconcileButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', hidden: true, onclick: () => ui.go('/price-list/reconcile') }, 'Reconcile');
|
|
54
|
-
const alerts = alertStrip(ui, 'price-list');
|
|
55
|
-
main.append(pageHeader('Price list', { help: { href: DOCS, label: 'How the price list works' }, actions: [reconcileButton, addButton] }), alerts.element, host);
|
|
56
101
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
102
|
+
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
|
|
103
|
+
const find = findBox(h, { label: 'Find a code or name', value: query, oninput: (words) => { query = words; draw(); } });
|
|
104
|
+
const icon = (name, label, onclick) => h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': label, title: label, onclick }, lucide(h, name));
|
|
105
|
+
const reconcileButton = icon('git-compare-arrows', 'Reconcile', () => ui.go('/price-list/reconcile'));
|
|
106
|
+
reconcileButton.hidden = true;
|
|
107
|
+
const settingsButton = icon('settings', 'Price list settings', () => openSettings());
|
|
108
|
+
const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled, title: disabled ? 'Only an owner or admin can add a code' : null, onclick: () => edit(null) }, lucide(h, 'plus'), 'New');
|
|
109
|
+
const alerts = alertStrip(ui, 'price-list');
|
|
110
|
+
const listTop = listHead('Price list', {
|
|
111
|
+
actions: [reconcileButton, settingsButton, addButton],
|
|
112
|
+
views: views.element, start: tabs, controls, controlsAt: 'end', end: find.element, line: true, sticky: true,
|
|
113
|
+
});
|
|
114
|
+
const [title, ...top] = listTop.nodes;
|
|
115
|
+
const frame = listFrame(main, host);
|
|
116
|
+
main.append(title, alerts.element, ...top, frame.element);
|
|
62
117
|
|
|
63
118
|
async function send(path, method, payload, done = 'Saved.') {
|
|
64
119
|
try {
|
|
@@ -71,8 +126,6 @@ export async function renderPriceList(main, ui) {
|
|
|
71
126
|
}
|
|
72
127
|
}
|
|
73
128
|
|
|
74
|
-
const row = (label, hint, control) => settingRow({ label, hint, control });
|
|
75
|
-
|
|
76
129
|
const findingText = (f) => {
|
|
77
130
|
const name = systemName(f.system);
|
|
78
131
|
if (f.kind === 'price') return `${name} price is ${money(f.other)}. Flow price is ${money(f.flow)}.`;
|
|
@@ -148,55 +201,70 @@ export async function renderPriceList(main, ui) {
|
|
|
148
201
|
return load();
|
|
149
202
|
}
|
|
150
203
|
|
|
151
|
-
|
|
152
|
-
|
|
204
|
+
// The price list's settings: one dialog from the head's settings icon.
|
|
205
|
+
async function openSettings() {
|
|
206
|
+
const select = h('select', { class: 'fx-select', 'aria-label': 'Invoice line price', disabled }, LINE_PRICES.map((p) => h('option', { value: p.value }, p.label)));
|
|
207
|
+
let saved = 'work';
|
|
153
208
|
try {
|
|
154
209
|
const { automations } = await flow('/api/invoicing/automations');
|
|
155
|
-
|
|
156
|
-
} catch {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (await send('/api/invoicing/automations/map_lines', 'PUT', { config: { linePrice: select.value } })) saved = select.value;
|
|
163
|
-
else select.value = saved;
|
|
164
|
-
select.disabled = disabled;
|
|
210
|
+
saved = automations.find((a) => a.key === 'map_lines')?.config.linePrice ?? 'work';
|
|
211
|
+
} catch { /* the default shows */ }
|
|
212
|
+
select.value = saved;
|
|
213
|
+
const ok = await dialog({
|
|
214
|
+
title: 'Price list settings',
|
|
215
|
+
body: h('div', { class: 'fx-fields' }, h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Invoice line price'), select)),
|
|
216
|
+
confirm: 'Save',
|
|
165
217
|
});
|
|
166
|
-
|
|
218
|
+
if (ok && select.value !== saved) await send('/api/invoicing/automations/map_lines', 'PUT', { config: { linePrice: select.value } });
|
|
167
219
|
}
|
|
168
220
|
|
|
221
|
+
// A group's name: a choice's label, or Yes / No.
|
|
222
|
+
const groupLabel = (item) => {
|
|
223
|
+
const value = priceValue(item, view.group);
|
|
224
|
+
if (view.group === 'tax') return taxName(value);
|
|
225
|
+
if (view.group === 'linked') return value.length ? value.map(systemName).join(', ') : 'Not linked';
|
|
226
|
+
return value ? 'Differs' : 'Agrees';
|
|
227
|
+
};
|
|
228
|
+
|
|
169
229
|
function draw() {
|
|
230
|
+
drawControls();
|
|
170
231
|
const words = query.trim().toLowerCase();
|
|
171
232
|
const live = data.items.filter((i) => !i.archivedAt);
|
|
172
233
|
const archived = data.items.filter((i) => i.archivedAt);
|
|
173
|
-
const
|
|
234
|
+
const kept = filterJobs(tab === 'archived' ? archived : live, view, { valueOf: priceValue, kindOf, late: null, today: today() })
|
|
235
|
+
.filter((i) => !words || `${i.code} ${i.name}`.toLowerCase().includes(words));
|
|
236
|
+
const shown = sortJobs(kept, view, (item, by) => {
|
|
237
|
+
const value = priceValue(item, by);
|
|
238
|
+
return typeof value === 'string' ? value.toLowerCase() : value;
|
|
239
|
+
});
|
|
174
240
|
const todo = data.systems.length ? reconcileRows(data).length : 0;
|
|
175
241
|
reconcileButton.hidden = !data.systems.length;
|
|
176
|
-
reconcileButton.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a code', value: query, 'aria-label': 'Find a code' });
|
|
180
|
-
search.addEventListener('input', () => { query = search.value; draw(); search.focus(); search.setSelectionRange(query.length, query.length); });
|
|
181
|
-
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' }, [['active', 'Active', live.length], ['archived', 'Archived', archived.length]].map(([id, label, count]) => h('button', {
|
|
242
|
+
reconcileButton.title = todo ? `Reconcile: ${todo} to check` : 'Reconcile';
|
|
243
|
+
frame.count(`${shown.length} ${shown.length === 1 ? 'code' : 'codes'}`);
|
|
244
|
+
tabs.replaceChildren(...[['active', 'Active', live.length], ['archived', 'Archived', archived.length]].map(([id, label, count]) => h('button', {
|
|
182
245
|
type: 'button', role: 'tab', 'aria-selected': String(tab === id), onclick: () => { tab = id; draw(); },
|
|
183
246
|
}, label, h('span', { class: 'fx-tabs__count' }, String(count)))));
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
247
|
+
|
|
248
|
+
if (!shown.length) {
|
|
249
|
+
host.replaceChildren(h('p', { class: 'fx-empty' }, words || view.groups.length ? 'No code matches.' : tab === 'archived' ? 'No archived codes.' : 'No codes yet.'));
|
|
250
|
+
} else {
|
|
251
|
+
const columns = view.columns.map((id) => ({ id, label: PRICE_COLUMNS[id], ...COLUMN_DEFS[id] }));
|
|
252
|
+
const itemRow = (item) => h('tr', { class: 'fx-row-link', tabindex: '0', onclick: () => edit(item), onkeydown: (event) => { if (event.key === 'Enter') edit(item); } },
|
|
253
|
+
rowCells(h, columns, item),
|
|
254
|
+
h('td', { class: 'fx-c-end' }));
|
|
255
|
+
const groups = view.group === 'none' ? [{ label: null, jobs: shown }] : groupJobs(shown, [], groupLabel)
|
|
256
|
+
.sort((x, y) => x.label.localeCompare(y.label) * (view.groupDir === 'desc' ? -1 : 1));
|
|
257
|
+
const head = ({ label, jobs }) => groupHead(h, {
|
|
258
|
+
label: `${label}, ${jobs.length} ${jobs.length === 1 ? 'code' : 'codes'}`,
|
|
259
|
+
badge: h('span', { class: 'fx-strong' }, label, ' ', h('span', { class: 'fx-tabs__count' }, String(jobs.length))),
|
|
260
|
+
open: !folded.has(label), onFold: () => { if (folded.has(label)) folded.delete(label); else folded.add(label); draw(); },
|
|
261
|
+
span: columns.length + 1,
|
|
262
|
+
});
|
|
263
|
+
host.replaceChildren(heads.table({
|
|
264
|
+
className: 'fx-table--rows fx-price__table', columns,
|
|
265
|
+
rows: groups.map((g) => h('tbody', {}, g.label == null ? null : head(g), g.label != null && folded.has(g.label) ? null : g.jobs.map(itemRow))),
|
|
266
|
+
}));
|
|
267
|
+
}
|
|
200
268
|
// Opened from Reconcile's "Edit code": that code's dialog, once.
|
|
201
269
|
if (opened) {
|
|
202
270
|
const item = data.items.find((i) => i.id === opened);
|
|
@@ -205,6 +273,8 @@ export async function renderPriceList(main, ui) {
|
|
|
205
273
|
if (item) edit(item);
|
|
206
274
|
}
|
|
207
275
|
}
|
|
276
|
+
// Folded groups, for this visit.
|
|
277
|
+
const folded = new Set();
|
|
208
278
|
|
|
209
279
|
async function load() {
|
|
210
280
|
try {
|
|
@@ -216,6 +286,5 @@ export async function renderPriceList(main, ui) {
|
|
|
216
286
|
draw();
|
|
217
287
|
}
|
|
218
288
|
|
|
219
|
-
addButton.addEventListener('click', () => edit(null));
|
|
220
289
|
await load();
|
|
221
290
|
}
|
package/server/ui/routes.js
CHANGED
|
@@ -12,6 +12,7 @@ import { themeStylesheet } from '../theme/themes.js';
|
|
|
12
12
|
// /assets/ui/<file> Flow's components (flow.css) and page scripts
|
|
13
13
|
// /assets/fonts/<pkg>/… the families' faces (server/theme/font-families.js)
|
|
14
14
|
// /assets/icons/<name>.svg one Lucide icon (lucide-static, ISC), for any page
|
|
15
|
+
// /assets/icons/tags.json each Lucide icon's words, for the icon picker
|
|
15
16
|
// Only the files named here are served. They change only with a release, so
|
|
16
17
|
// browsers may keep them for an hour.
|
|
17
18
|
|
|
@@ -21,7 +22,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
21
22
|
const logoDir = join(here, 'logos');
|
|
22
23
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
23
24
|
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', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
25
|
+
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', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
25
26
|
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
26
27
|
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
27
28
|
const MAX_AGE = '1h';
|
|
@@ -58,6 +59,8 @@ export function createUiAssetsRouter({ rateLimitPerMinute = 600 } = {}) {
|
|
|
58
59
|
if (!ICON_FILE.test(req.params.file)) return next();
|
|
59
60
|
sendNamed(res, next, iconDir, req.params.file);
|
|
60
61
|
});
|
|
62
|
+
// Each icon's words, for the icon picker's search (kit.js iconPicker).
|
|
63
|
+
router.get('/assets/icons/tags.json', (_req, res, next) => sendNamed(res, next, dirname(iconDir), 'tags.json'));
|
|
61
64
|
|
|
62
65
|
for (const name of FONT_PACKAGES) {
|
|
63
66
|
const dir = dirname(require.resolve(`@fontsource-variable/${name}/LICENSE`));
|
package/server/ui/saved-views.js
CHANGED
|
@@ -116,6 +116,8 @@ export function savedViews(ui, { page, current, apply }) {
|
|
|
116
116
|
}, isDefault ? lucide(h, 'house', 'Your default') : null, h('span', { class: 'fx-views__name' }, v.name), on && changed ? h('span', { class: 'fx-views__dot', 'aria-label': 'Changed', title: 'Changed' }) : null),
|
|
117
117
|
more, menu);
|
|
118
118
|
});
|
|
119
|
+
// A tab list needs tabs: with no saved view yet it is a plain group.
|
|
120
|
+
element.setAttribute('role', tabs.length ? 'tablist' : 'group');
|
|
119
121
|
element.replaceChildren(...[
|
|
120
122
|
...tabs,
|
|
121
123
|
saved.list.length < MAX_SAVED_VIEWS ? h('button', { type: 'button', class: 'fx-views__add', title: 'Save as a view', onclick: addView }, lucide(h, 'plus'), 'View') : null,
|
package/server/ui/view-bar.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The view bar over a list: chips that open a small panel (Group, Sort,
|
|
2
2
|
// Filter, Columns), as on ClickUp. Every list page builds its chips here, so
|
|
3
|
-
// they look and work the same: Jobs
|
|
3
|
+
// they look and work the same: Jobs, Invoices, Reconcile and the Price list.
|
|
4
4
|
//
|
|
5
5
|
// viewBar(h) answers the parts:
|
|
6
6
|
// menu(icon, build, { rows, clearLabel }) a chip, its × and its panel.
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// option(text, checked, onclick, role, icon) a row in a panel, with a tick.
|
|
10
10
|
// title, choose, bin, addButton, rowsFoot, clearAll the parts of a rows
|
|
11
11
|
// panel: a title, rows of selects with a bin, a foot with Add and Clear.
|
|
12
|
+
// groupMenu(config), sortMenu(config) the Group and Sort chips (below).
|
|
12
13
|
// filterMenu(config) the Filter chip, as ClickUp's: groups of rows of
|
|
13
14
|
// field, operator and value, And / Or in a group and between groups,
|
|
14
15
|
// "Add nested filter", "Add filter" and "Clear all" (job-view.js has
|
|
@@ -161,6 +162,64 @@ export function viewBar(h) {
|
|
|
161
162
|
pop.style.left = `${Math.round(left)}px`;
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
// ── Group by ──
|
|
166
|
+
// One row: what to group by, the group order, and a bin that takes the
|
|
167
|
+
// groups away when `clearable()` (Jobs' board always has lanes). The page
|
|
168
|
+
// names the chip with set(), as only it knows its group's name.
|
|
169
|
+
// options() [{ value, label } | { group, options }]
|
|
170
|
+
// view() { group, groupDir }; group 'none' is no groups
|
|
171
|
+
function groupMenu({ options, view, change, clearable = () => true }) {
|
|
172
|
+
return menu(ICONS.group, (redraw) => {
|
|
173
|
+
const { group, groupDir } = view();
|
|
174
|
+
const none = group === 'none';
|
|
175
|
+
return [
|
|
176
|
+
title('Group by'),
|
|
177
|
+
h('div', { class: 'fx-rows' }, h('div', { class: 'fx-rows__row fx-rows__row--group' },
|
|
178
|
+
choose('Group by', none ? '' : group, [...(none ? [{ value: '', label: 'Choose…', disabled: true }] : []), ...options()], (value) => { change({ group: value }); redraw(); }),
|
|
179
|
+
choose('Group order', groupDir, [{ value: 'asc', label: 'Ascending' }, { value: 'desc', label: 'Descending' }], (value) => { change({ groupDir: value }); redraw(); }, { disabled: none }),
|
|
180
|
+
clearable() && !none ? bin('Remove the grouping', () => { change({ group: 'none' }); redraw(); }) : h('span', { class: 'fx-rows__gap' }))),
|
|
181
|
+
];
|
|
182
|
+
}, { rows: true, clearLabel: 'Remove the grouping' });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Sort ──
|
|
186
|
+
// Rows of sorts, each applied in turn; a list's column heads sort too.
|
|
187
|
+
// options() [{ value, label } | { group, options }]
|
|
188
|
+
// view() { sorts: [{ by, dir }] }
|
|
189
|
+
// dirWords(by) the two directions' words, as ['A to Z', 'Z to A']
|
|
190
|
+
// nameOf(by) the chip's name for a sort
|
|
191
|
+
function sortMenu({ options, view, change, dirWords, nameOf, empty = 'No sort.' }) {
|
|
192
|
+
const setSorts = (sorts) => change({ sorts });
|
|
193
|
+
const control = menu(ICONS.sort, (redraw) => {
|
|
194
|
+
const { sorts } = view();
|
|
195
|
+
const unused = options().flatMap((o) => o.options ?? [o]).filter((o) => !sorts.some((s) => s.by === o.value));
|
|
196
|
+
return [
|
|
197
|
+
title('Sort'),
|
|
198
|
+
sorts.length ? h('div', { class: 'fx-rows' }, sorts.map((s, i) => {
|
|
199
|
+
const [up, down] = dirWords(s.by);
|
|
200
|
+
const others = sorts.filter((_, n) => n !== i).map((x) => x.by);
|
|
201
|
+
const shown = options().map((o) => (o.group ? { ...o, options: o.options.filter((x) => !others.includes(x.value)) } : o)).filter((o) => o.group || !others.includes(o.value));
|
|
202
|
+
return h('div', { class: 'fx-rows__row fx-rows__row--sort' },
|
|
203
|
+
h('span', { class: 'fx-rows__lead' }, i ? 'then' : 'Sort by'),
|
|
204
|
+
choose('Sort by', s.by, shown, (by) => { setSorts(sorts.map((x, n) => (n === i ? { by, dir: x.dir } : x))); redraw(); }),
|
|
205
|
+
choose('Order', s.dir, [{ value: 'asc', label: up }, { value: 'desc', label: down }], (dir) => { setSorts(sorts.map((x, n) => (n === i ? { ...x, dir } : x))); redraw(); }),
|
|
206
|
+
bin('Remove this sort', () => { setSorts(sorts.filter((_, n) => n !== i)); redraw(); }));
|
|
207
|
+
})) : h('p', { class: 'fx-rows__empty' }, empty),
|
|
208
|
+
rowsFoot(
|
|
209
|
+
sorts.length < 5 && unused.length ? addButton('Add sort', () => { setSorts([...sorts, { by: unused[0].value, dir: 'asc' }]); redraw(); }) : h('span'),
|
|
210
|
+
sorts.length ? clearAll(() => { setSorts([]); redraw(); }) : null),
|
|
211
|
+
];
|
|
212
|
+
}, { rows: true, clearLabel: 'Clear the sort' });
|
|
213
|
+
// The chip names the sort that is on.
|
|
214
|
+
const draw = () => {
|
|
215
|
+
const { sorts } = view();
|
|
216
|
+
const [first] = sorts;
|
|
217
|
+
control.set(sorts.length > 1 ? `Sort · ${sorts.length}` : first ? `Sort: ${nameOf(first.by)} ${first.dir === 'desc' ? '↓' : '↑'}` : 'Sort', sorts.length > 0,
|
|
218
|
+
sorts.length ? () => setSorts([]) : null);
|
|
219
|
+
};
|
|
220
|
+
return { ...control, draw };
|
|
221
|
+
}
|
|
222
|
+
|
|
164
223
|
// ── Filters ──
|
|
165
224
|
// As ClickUp's: groups of rows of field, operator and value, with a bin on
|
|
166
225
|
// each row. A group's second row has And / Or for the rows in it; the
|
|
@@ -329,7 +388,7 @@ export function viewBar(h) {
|
|
|
329
388
|
return { ...control, draw };
|
|
330
389
|
}
|
|
331
390
|
|
|
332
|
-
return { menu, option, title, choose, bin, addButton, rowsFoot, clearAll, filterMenu };
|
|
391
|
+
return { menu, option, title, choose, bin, addButton, rowsFoot, clearAll, groupMenu, sortMenu, filterMenu };
|
|
333
392
|
}
|
|
334
393
|
|
|
335
394
|
// A list's own search: a round search icon in the view bar that opens into a
|