@arcforgelabs/flow 2026.10.42 → 2026.10.44
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 +4 -3
- package/package.json +5 -3
- package/server/auth/people.js +40 -2
- package/server/auth/routes.js +11 -0
- package/server/auth/session.js +22 -3
- package/server/core/custom-fields.js +4 -3
- package/server/core/invoice-rules.js +8 -3
- package/server/core/job-match.js +6 -2
- package/server/core/sending.js +4 -3
- package/server/integrations/sentry/index.js +9 -1
- package/server/release-channel.js +5 -2
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +46 -162
- package/server/ui/automations.js +9 -3
- package/server/ui/columns.js +268 -0
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +268 -55
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +92 -35
- package/server/ui/invoicing.js +206 -119
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/job-view.js +255 -60
- package/server/ui/jobs.js +197 -145
- package/server/ui/kit.js +278 -0
- package/server/ui/list-views.js +73 -0
- package/server/ui/monitoring.js +1 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- package/server/ui/reconcile.js +114 -61
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +141 -0
- package/server/ui/search.js +8 -0
- package/server/ui/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/view-bar.js +176 -70
- package/server/ui/xero-import.js +7 -10
package/server/ui/reconcile.js
CHANGED
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
// /jobs/:id/no-invoice (docs/JOB_INVOICE_LINKS.md).
|
|
18
18
|
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
19
19
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
20
|
-
import {
|
|
20
|
+
import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
21
|
+
import { statusRank } from '/assets/ui/job-board.js';
|
|
22
|
+
import { filterJobs, KIND_OF_TYPE, normaliseListView, RECONCILE_COLUMNS } from '/assets/ui/job-view.js';
|
|
23
|
+
import { normaliseReconcileView, RECONCILE_FILTER_FIELDS } from '/assets/ui/list-views.js';
|
|
24
|
+
import { savedViews } from '/assets/ui/saved-views.js';
|
|
21
25
|
import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
|
|
22
26
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
23
27
|
|
|
@@ -97,15 +101,10 @@ const CHEVRONS = { prev: 'M14.5 6l-6 6 6 6', next: 'M9.5 6l6 6-6 6' };
|
|
|
97
101
|
const FILTER_KEY = 'flow_reconcile_filters';
|
|
98
102
|
const NAV_KEY = 'flow_reconcile_nav';
|
|
99
103
|
// The fields a filter row can use. One word each; Type's choices have icons.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
date: { label: 'Date', kind: 'date' },
|
|
105
|
-
total: { label: 'Total', kind: 'number' },
|
|
106
|
-
match: { label: 'Match', kind: 'check' },
|
|
107
|
-
};
|
|
108
|
-
const MATCH_OPS = { yes: 'has one', no: 'has none' };
|
|
104
|
+
// The filter's fields: list-views.js (the server checks saved views with them).
|
|
105
|
+
const FILTER_FIELDS = RECONCILE_FILTER_FIELDS;
|
|
106
|
+
const MATCH_OPS = { yes: 'Has one', no: 'Has none' };
|
|
107
|
+
const SET_OPS = { is: 'Is', not: 'Is not' };
|
|
109
108
|
const TYPES = [{ value: 'job', label: 'Job', icon: 'briefcase' }, { value: 'invoice', label: 'Invoice', icon: 'file-text' }];
|
|
110
109
|
|
|
111
110
|
function readStored(store, key, fallback) {
|
|
@@ -118,35 +117,47 @@ function writeStored(store, key, value) {
|
|
|
118
117
|
// One row for each gap, jobs and invoices together, oldest first.
|
|
119
118
|
export function reconcileRows({ jobs = [], invoices = [] }) {
|
|
120
119
|
return [
|
|
121
|
-
...jobs.map((j) => ({
|
|
122
|
-
|
|
120
|
+
...jobs.map((j) => ({
|
|
121
|
+
kind: 'job', id: j.id, client: j.clientName, who: j.practitioner, stage: j.status ?? null, date: j.jobDate, totalCents: j.totalCents, possible: j.possible, unclear: j.unclear,
|
|
122
|
+
key: j.sourceSystem && j.sourceJobRef ? `${j.sourceSystem}\u0000${j.sourceJobRef}` : null,
|
|
123
|
+
})),
|
|
124
|
+
...invoices.map((i) => ({ kind: 'invoice', id: i.id, client: i.clientName, who: i.number ?? 'Draft', status: i.status, stage: statusWords(i.status), date: i.issueDate, totalCents: i.totalCents, possible: i.possible })),
|
|
123
125
|
].sort((a, b) => String(a.date ?? '9999').localeCompare(String(b.date ?? '9999')));
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
+
const isCustom = (field) => /^field:/.test(field);
|
|
129
|
+
|
|
130
|
+
// A row's value for a filter field, as the filter compares it. `valueOf`
|
|
131
|
+
// reads a job's custom field value: (row, fieldId) => value.
|
|
132
|
+
export function rowValue(row, field, valueOf = () => null) {
|
|
128
133
|
switch (field) {
|
|
129
134
|
case 'type': return row.kind;
|
|
135
|
+
case 'status': return row.stage ?? null;
|
|
130
136
|
case 'client': return row.client ?? null;
|
|
131
137
|
case 'practitioner': return row.kind === 'job' ? row.who ?? null : null;
|
|
132
138
|
case 'date': return row.date ?? null;
|
|
133
139
|
case 'total': return row.totalCents == null ? null : row.totalCents / 100;
|
|
134
140
|
case 'match': return row.possible > 0;
|
|
135
|
-
default: return null;
|
|
141
|
+
default: return isCustom(field) && row.kind === 'job' ? valueOf(row, field.slice(6)) : null;
|
|
136
142
|
}
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
//
|
|
140
|
-
|
|
145
|
+
// A view without the rows a record does not have: an invoice has no
|
|
146
|
+
// practitioner and no custom fields.
|
|
147
|
+
const forInvoices = (view) => ({ ...view, groups: view.groups.map((g) => ({ ...g, filters: g.filters.filter((f) => !FILTER_FIELDS[f.field]?.job && !isCustom(f.field)) })) });
|
|
148
|
+
|
|
149
|
+
// The rows a view keeps: its filter rows, then the search words. `kindOf`
|
|
150
|
+
// gives a field's kind (custom fields too); `valueOf` a custom value.
|
|
151
|
+
export function keptRows(rows, view, q = '', { kindOf = (field) => FILTER_FIELDS[field]?.kind ?? null, valueOf = () => null, now = today() } = {}) {
|
|
141
152
|
const words = q.trim().toLowerCase();
|
|
142
|
-
|
|
153
|
+
const keep = (list, v) => filterJobs(list, v, { valueOf: (row, field) => rowValue(row, field, valueOf), kindOf, today: now });
|
|
154
|
+
const kept = new Set([...keep(rows.filter((r) => r.kind === 'job'), view), ...keep(rows.filter((r) => r.kind !== 'job'), forInvoices(view))]);
|
|
155
|
+
return rows.filter((r) => kept.has(r))
|
|
143
156
|
.filter((r) => !words || [r.client, r.who, money(r.totalCents)].some((v) => String(v ?? '').toLowerCase().includes(words)));
|
|
144
157
|
}
|
|
145
158
|
|
|
146
159
|
function readView() {
|
|
147
|
-
|
|
148
|
-
const filters = Array.isArray(saved.filters) ? saved.filters.filter((f) => f && Object.hasOwn(FILTER_FIELDS, f.field)) : [];
|
|
149
|
-
return { match: MATCHES.includes(saved.match) ? saved.match : 'all', filters };
|
|
160
|
+
return normaliseReconcileView(readStored(localStorage, FILTER_KEY, {}));
|
|
150
161
|
}
|
|
151
162
|
|
|
152
163
|
export async function renderReconcile(main, ui, id = null) {
|
|
@@ -158,43 +169,84 @@ const pathOf = (row) => `/reconcile/${row.id}${row.kind === 'invoice' ? '?kind=i
|
|
|
158
169
|
const KIND_ICON = { job: ['briefcase', 'Job'], invoice: ['file-text', 'Invoice'] };
|
|
159
170
|
|
|
160
171
|
async function renderList(main, ui) {
|
|
161
|
-
const { h, flow, toast,
|
|
172
|
+
const { h, flow, toast, listHead, listFrame } = ui;
|
|
162
173
|
const icon = (name, label) => lucide(h, name, label);
|
|
163
174
|
const canAct = ['owner', 'admin'].includes(ui.me?.person?.role);
|
|
164
175
|
const back = readStored(sessionStorage, NAV_KEY, null);
|
|
165
|
-
const state = { view: readView(), q: back?.q ?? '', ticked: new Set() };
|
|
176
|
+
const state = { view: readView(), q: back?.q ?? '', ticked: new Set(), columns: normaliseListView(ui.me?.views?.reconcile, RECONCILE_COLUMNS) };
|
|
177
|
+
// The list's columns: resize, move, hide, kept with the account.
|
|
178
|
+
const saveColumns = viewSaver(ui, 'reconcile');
|
|
179
|
+
const heads = columnTable(h, {
|
|
180
|
+
order: () => state.columns.columns,
|
|
181
|
+
widths: () => state.columns.widths,
|
|
182
|
+
change: (patch) => {
|
|
183
|
+
state.columns = normaliseListView({ ...state.columns, ...patch }, RECONCILE_COLUMNS);
|
|
184
|
+
saveColumns(state.columns);
|
|
185
|
+
draw();
|
|
186
|
+
},
|
|
187
|
+
hidden: () => Object.keys(RECONCILE_COLUMNS).filter((id) => !state.columns.columns.includes(id)).map((id) => ({ id, label: RECONCILE_COLUMNS[id] })),
|
|
188
|
+
});
|
|
189
|
+
let rows;
|
|
190
|
+
// A change to the filter: kept in this browser, and drawn.
|
|
191
|
+
const setView = (patch) => {
|
|
192
|
+
state.view = normaliseReconcileView({ ...state.view, ...patch });
|
|
193
|
+
writeStored(localStorage, FILTER_KEY, state.view);
|
|
194
|
+
views.draw();
|
|
195
|
+
if (rows) draw();
|
|
196
|
+
};
|
|
197
|
+
// The person's named views (saved-views.js): their default opens on a
|
|
198
|
+
// new visit.
|
|
199
|
+
const views = savedViews(ui, { page: 'reconcile', current: () => state.view, apply: setView });
|
|
200
|
+
const opening = views.opening();
|
|
201
|
+
if (opening) state.view = normaliseReconcileView(opening);
|
|
166
202
|
const find = findBox(h, { label: 'Find a client, practitioner or amount', value: state.q, oninput: (words) => { state.q = words; draw(); } });
|
|
167
203
|
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Filter' });
|
|
168
204
|
const host = h('div', { class: 'fx-rec' }, h('div', { class: 'fx-skeleton' }));
|
|
169
205
|
const bulk = bulkBar(ui, { label: 'Ticked jobs' });
|
|
170
|
-
main
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
206
|
+
const frame = listFrame(main, host);
|
|
207
|
+
main.append(...listHead('Reconcile', { views: views.element, start: controls, end: find.element }).nodes, frame.element, bulk.element);
|
|
208
|
+
|
|
209
|
+
// The Filter chip: the same panel as on Jobs (view-bar.js). Job rows also
|
|
210
|
+
// have the lab's custom fields.
|
|
211
|
+
const custom = { fields: [], people: [], values: new Map() };
|
|
212
|
+
const fieldById = (id) => custom.fields.find((f) => f.id === id) ?? null;
|
|
213
|
+
const kindOf = (field) => FILTER_FIELDS[field]?.kind ?? (isCustom(field) ? KIND_OF_TYPE[fieldById(field.slice(6))?.type] ?? null : null);
|
|
214
|
+
const valueOf = (row, fieldId) => (row.key ? custom.values.get(`${row.key}\u0000${fieldId}`) ?? null : null);
|
|
176
215
|
const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
|
|
177
216
|
const filterMenu = viewBar(h).filterMenu({
|
|
178
|
-
fields: () =>
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
217
|
+
fields: () => [
|
|
218
|
+
...Object.entries(FILTER_FIELDS).map(([value, f]) => ({ value, label: f.label, icon: f.icon })),
|
|
219
|
+
...custom.fields.filter((f) => KIND_OF_TYPE[f.type]).map((f) => ({ value: `field:${f.id}`, label: f.name })),
|
|
220
|
+
],
|
|
221
|
+
kindOf,
|
|
222
|
+
opsOf: (field) => (field === 'match' ? MATCH_OPS : ['type', 'status'].includes(field) ? SET_OPS : null),
|
|
223
|
+
choicesOf: (field) => {
|
|
224
|
+
if (field === 'type') return TYPES;
|
|
225
|
+
if (field === 'status') return labels(rows.filter((r) => r.kind === 'job').map((r) => r.stage)).map((c) => ({ ...c, rank: statusRank(c.value) }))
|
|
226
|
+
.concat(labels(rows.filter((r) => r.kind !== 'job').map((r) => r.stage)).filter((c) => !rows.some((r) => r.kind === 'job' && r.stage === c.value)));
|
|
227
|
+
if (field === 'client') return labels(rows.map((r) => r.client));
|
|
228
|
+
if (field === 'practitioner') return labels(rows.filter((r) => r.kind === 'job').map((r) => r.who));
|
|
229
|
+
const f = isCustom(field) ? fieldById(field.slice(6)) : null;
|
|
230
|
+
if (f?.type === 'person') return [{ value: 'me', label: 'Me' }, ...custom.people.map((p) => ({ value: p.id, label: p.name }))];
|
|
231
|
+
return (f?.options ?? []).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name, color: o.color }));
|
|
189
232
|
},
|
|
233
|
+
view: () => state.view,
|
|
234
|
+
change: setView,
|
|
190
235
|
first: 'type',
|
|
191
236
|
empty: 'No filters. Every job and invoice shows.',
|
|
192
237
|
});
|
|
193
238
|
controls.append(...filterMenu.node);
|
|
194
239
|
|
|
195
|
-
|
|
240
|
+
views.draw();
|
|
196
241
|
try {
|
|
197
|
-
|
|
242
|
+
// Custom fields are extra: the list still shows without them.
|
|
243
|
+
const [lists, fields, values] = await Promise.all([
|
|
244
|
+
flow('/api/invoicing/reconcile'), flow('/api/custom-fields').catch(() => null), flow('/api/custom-fields/values').catch(() => null),
|
|
245
|
+
]);
|
|
246
|
+
rows = reconcileRows(lists);
|
|
247
|
+
custom.fields = fields?.fields ?? [];
|
|
248
|
+
custom.people = fields?.people ?? [];
|
|
249
|
+
custom.values = new Map((values?.values ?? []).map((v) => [`${v.sourceSystem}\u0000${v.sourceJobRef}\u0000${v.fieldId}`, v.value]));
|
|
198
250
|
} catch (error) {
|
|
199
251
|
host.replaceChildren(h('p', { class: 'fx-empty' }, error.message));
|
|
200
252
|
return;
|
|
@@ -202,8 +254,9 @@ async function renderList(main, ui) {
|
|
|
202
254
|
|
|
203
255
|
function draw() {
|
|
204
256
|
filterMenu.draw();
|
|
205
|
-
const shown = keptRows(rows, state.view, state.q);
|
|
257
|
+
const shown = keptRows(rows, state.view, state.q, { kindOf, valueOf });
|
|
206
258
|
const seen = new Set(shown.map((r) => r.id));
|
|
259
|
+
frame.count(`${shown.length} to check`);
|
|
207
260
|
for (const key of state.ticked) if (!seen.has(key)) state.ticked.delete(key);
|
|
208
261
|
const remember = () => writeStored(sessionStorage, NAV_KEY, { q: state.q, rows: shown.map(({ kind, id }) => ({ kind, id })) });
|
|
209
262
|
if (!rows.length) {
|
|
@@ -252,31 +305,31 @@ async function renderList(main, ui) {
|
|
|
252
305
|
const possible = (r) => (r.possible
|
|
253
306
|
? h('span', { class: 'fx-rec__possible', 'data-state': 'some' }, icon('link-2', 'Possible match'), String(r.possible))
|
|
254
307
|
: h('span', { class: 'fx-rec__possible' }, icon('circle-dashed', 'No possible match')));
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
308
|
+
const ALL = {
|
|
309
|
+
kind: { kind: 'icon', icon: 'layers', className: 'fx-rec-list__kind', cell: (r) => icon(...KIND_ICON[r.kind]) },
|
|
310
|
+
client: { kind: 'client', className: 'fx-strong', cell: (r) => r.client ?? h('span', { class: 'fx-dim' }, 'No client') },
|
|
311
|
+
detail: { kind: 'text', cell: (r) => h('span', { class: r.kind === 'invoice' ? 'fx-num' : 'fx-dim' }, r.who ?? '—', r.kind === 'invoice' ? h('span', { class: 'fx-dim' }, ` · ${statusWords(r.status)}`) : null) },
|
|
312
|
+
date: { kind: 'date', className: 'fx-dim', cell: (r) => shortDate(r.date) },
|
|
313
|
+
total: { kind: 'total', num: true, cell: (r) => money(r.totalCents) },
|
|
314
|
+
possible: { kind: 'status', className: 'fx-rec-list__mid', cell: possible },
|
|
315
|
+
};
|
|
316
|
+
const columns = state.columns.columns.map((id) => ({ id, label: RECONCILE_COLUMNS[id], ...ALL[id] }));
|
|
317
|
+
host.replaceChildren(h('div', { class: 'fx-panel' }, heads.table({
|
|
318
|
+
className: 'fx-table--rows fx-rec-list',
|
|
319
|
+
columns,
|
|
320
|
+
lead: canAct ? [h('th', { class: 'fx-tick-cell' }, all)] : [],
|
|
321
|
+
rows: h('tbody', {}, shown.map((r) => {
|
|
265
322
|
const box = r.kind === 'job' ? h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': `Tick ${r.client ?? 'job'}`, checked: state.ticked.has(r.id), onclick: (event) => event.stopPropagation(), onchange: () => {
|
|
266
323
|
if (box.checked) state.ticked.add(r.id); else state.ticked.delete(r.id);
|
|
267
324
|
drawBar();
|
|
268
325
|
} }) : null;
|
|
269
326
|
if (box) boxes.set(r.id, box);
|
|
270
|
-
const [glyph, label] = KIND_ICON[r.kind];
|
|
271
327
|
return h('tr', { 'data-kind': r.kind, onclick: () => { remember(); ui.go(pathOf(r)); } },
|
|
272
328
|
canAct ? h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, box) : null,
|
|
273
|
-
h
|
|
274
|
-
h('td', { class: 'fx-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
h('td', { class: 'fx-num' }, money(r.totalCents)),
|
|
278
|
-
h('td', { class: 'fx-rec-list__mid' }, possible(r)));
|
|
279
|
-
})))));
|
|
329
|
+
rowCells(h, columns, r),
|
|
330
|
+
h('td', { class: 'fx-c-end' }));
|
|
331
|
+
})),
|
|
332
|
+
})));
|
|
280
333
|
drawBar();
|
|
281
334
|
}
|
|
282
335
|
draw();
|
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
|
-
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.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', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.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', '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';
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Saved views: the row of named view tabs over a list (Jobs, Invoices,
|
|
2
|
+
// Reconcile), as ClickUp's. A view keeps the page's Group, Sort, Filter and
|
|
3
|
+
// Columns under a name. A tab shows it; "Save" and "Revert" show when the
|
|
4
|
+
// page no longer matches the chosen tab; "+ View" saves the page as a new
|
|
5
|
+
// view. Each tab's ⋯ menu has "Set as my default" (the view that opens
|
|
6
|
+
// when the person comes to the page), Rename and Delete. The views follow
|
|
7
|
+
// the person's account (PUT /api/me/saved-views/:page, list-views.js).
|
|
8
|
+
//
|
|
9
|
+
// const views = savedViews(ui, { page, current, apply });
|
|
10
|
+
// views.element the tab row, for the page to place
|
|
11
|
+
// views.opening() the view to open on, or null: the default view on the
|
|
12
|
+
// first visit in this browser tab, else the page's own
|
|
13
|
+
// views.draw() call after any change to the page's view
|
|
14
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
15
|
+
import { rowMenu } from '/assets/ui/kit.js';
|
|
16
|
+
import { MAX_SAVED_VIEWS, normaliseSavedViews, sameView } from '/assets/ui/list-views.js';
|
|
17
|
+
|
|
18
|
+
const ACTIVE_KEY = (page) => `flow_view_tab_${page}`;
|
|
19
|
+
|
|
20
|
+
function readActive(page) {
|
|
21
|
+
try { return sessionStorage.getItem(ACTIVE_KEY(page)); } catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
function writeActive(page, id) {
|
|
24
|
+
try {
|
|
25
|
+
if (id) sessionStorage.setItem(ACTIVE_KEY(page), id);
|
|
26
|
+
else sessionStorage.removeItem(ACTIVE_KEY(page));
|
|
27
|
+
} catch { /* the tab is forgotten on the next visit */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const newId = () => (globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`);
|
|
31
|
+
|
|
32
|
+
export function savedViews(ui, { page, current, apply }) {
|
|
33
|
+
const { h, flow, dialog, toast } = ui;
|
|
34
|
+
let saved = normaliseSavedViews(page, ui.me?.views?.saved?.[page]);
|
|
35
|
+
let active = null;
|
|
36
|
+
const element = h('div', { class: 'fx-views', role: 'tablist', 'aria-label': 'Views' });
|
|
37
|
+
const chosen = () => saved.list.find((v) => v.id === active) ?? null;
|
|
38
|
+
|
|
39
|
+
async function store(next) {
|
|
40
|
+
const before = saved;
|
|
41
|
+
saved = normaliseSavedViews(page, next);
|
|
42
|
+
if (ui.me) ui.me.views = { ...ui.me.views, saved: { ...ui.me.views?.saved, [page]: saved } };
|
|
43
|
+
draw();
|
|
44
|
+
try {
|
|
45
|
+
await flow(`/api/me/saved-views/${page}`, { method: 'PUT', body: JSON.stringify(saved) });
|
|
46
|
+
} catch {
|
|
47
|
+
saved = before;
|
|
48
|
+
draw();
|
|
49
|
+
toast?.('Flow could not save your views. Try again.', 'danger');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function choose(id) {
|
|
54
|
+
active = id;
|
|
55
|
+
writeActive(page, id);
|
|
56
|
+
const view = chosen();
|
|
57
|
+
if (view) apply(view.view);
|
|
58
|
+
draw();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A name for a view: a dialog with one box.
|
|
62
|
+
async function askName(title, value, confirm) {
|
|
63
|
+
const data = await dialog({
|
|
64
|
+
title,
|
|
65
|
+
body: h('div', { class: 'fx-fields' }, h('label', { class: 'fx-field' },
|
|
66
|
+
h('span', { class: 'fx-label' }, 'Name'),
|
|
67
|
+
h('input', { class: 'fx-input', name: 'name', required: true, maxlength: '60', autocomplete: 'off', value }))),
|
|
68
|
+
confirm,
|
|
69
|
+
});
|
|
70
|
+
const name = data?.get('name')?.toString().trim();
|
|
71
|
+
return name || null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function addView() {
|
|
75
|
+
const name = await askName('Save view', `View ${saved.list.length + 1}`, 'Save');
|
|
76
|
+
if (!name) return;
|
|
77
|
+
const id = newId();
|
|
78
|
+
await store({ ...saved, list: [...saved.list, { id, name, view: current() }] });
|
|
79
|
+
active = id;
|
|
80
|
+
writeActive(page, id);
|
|
81
|
+
draw();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function rename(view) {
|
|
85
|
+
const name = await askName('Rename view', view.name, 'Rename');
|
|
86
|
+
if (name) await store({ ...saved, list: saved.list.map((v) => (v.id === view.id ? { ...v, name } : v)) });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function remove(view) {
|
|
90
|
+
const ok = await dialog({ title: `Delete "${view.name}"?`, body: h('p', {}, 'The list stays as it is now.'), confirm: 'Delete', tone: 'danger' });
|
|
91
|
+
if (!ok) return;
|
|
92
|
+
if (active === view.id) { active = null; writeActive(page, null); }
|
|
93
|
+
await store({ list: saved.list.filter((v) => v.id !== view.id), default: saved.default === view.id ? null : saved.default });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const setDefault = (view) => store({ ...saved, default: saved.default === view.id ? null : view.id });
|
|
97
|
+
|
|
98
|
+
function draw() {
|
|
99
|
+
const view = chosen();
|
|
100
|
+
const changed = Boolean(view) && !sameView(page, view.view, current());
|
|
101
|
+
const tabs = saved.list.map((v) => {
|
|
102
|
+
const on = v.id === active;
|
|
103
|
+
const isDefault = saved.default === v.id;
|
|
104
|
+
const [more, menu] = rowMenu(`More for ${v.name}`, [
|
|
105
|
+
{ icon: isDefault ? 'house' : 'house-plus', text: isDefault ? 'Not my default' : 'Set as my default', run: () => setDefault(v) },
|
|
106
|
+
{ icon: 'pencil', text: 'Rename', run: () => rename(v) },
|
|
107
|
+
'-',
|
|
108
|
+
{ icon: 'trash-2', text: 'Delete', tone: 'danger', run: () => remove(v) },
|
|
109
|
+
], { align: 'start' });
|
|
110
|
+
return h('span', { class: 'fx-views__item', 'data-on': String(on) },
|
|
111
|
+
h('button', {
|
|
112
|
+
type: 'button', class: 'fx-views__tab', role: 'tab', 'aria-selected': String(on),
|
|
113
|
+
title: isDefault ? `${v.name} (your default)` : v.name,
|
|
114
|
+
onclick: () => choose(v.id),
|
|
115
|
+
}, 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),
|
|
116
|
+
more, menu);
|
|
117
|
+
});
|
|
118
|
+
element.replaceChildren(...[
|
|
119
|
+
...tabs,
|
|
120
|
+
saved.list.length < MAX_SAVED_VIEWS ? h('button', { type: 'button', class: 'fx-views__add', onclick: addView }, lucide(h, 'plus'), 'View') : null,
|
|
121
|
+
changed ? h('span', { class: 'fx-views__end' },
|
|
122
|
+
h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick: () => { apply(view.view); draw(); } }, 'Revert'),
|
|
123
|
+
h('button', { type: 'button', class: 'fx-btn fx-btn--primary fx-btn--sm', onclick: () => store({ ...saved, list: saved.list.map((v) => (v.id === view.id ? { ...v, view: current() } : v)) }) }, 'Save')) : null,
|
|
124
|
+
].filter(Boolean));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The view the page opens on. In the same browser tab, the tab chosen
|
|
128
|
+
// last stays chosen and the page keeps its own view; on a new visit the
|
|
129
|
+
// person's default view opens.
|
|
130
|
+
function opening() {
|
|
131
|
+
const kept = readActive(page);
|
|
132
|
+
if (kept && saved.list.some((v) => v.id === kept)) { active = kept; return null; }
|
|
133
|
+
const first = saved.list.find((v) => v.id === saved.default);
|
|
134
|
+
if (!first) return null;
|
|
135
|
+
active = first.id;
|
|
136
|
+
writeActive(page, first.id);
|
|
137
|
+
return first.view;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { element, opening, draw };
|
|
141
|
+
}
|
package/server/ui/search.js
CHANGED
|
@@ -8,6 +8,14 @@ const LIMIT = 6;
|
|
|
8
8
|
|
|
9
9
|
const lower = (value) => String(value ?? '').toLowerCase();
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* True for Ctrl+K or ⌘K. Some keydown events carry no `key` (Chrome sends
|
|
13
|
+
* them when it fills a form from saved details), so a missing key is no match.
|
|
14
|
+
*/
|
|
15
|
+
export function isSearchKey(event) {
|
|
16
|
+
return lower(event.key) === 'k' && Boolean(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey;
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
/**
|
|
12
20
|
* `views` is [{ name, label, icon }] for the pages this person may open;
|
|
13
21
|
* `icon(name)` draws a sidebar icon; `go(path)` opens a path in place.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The sidecar, as in ClickUp: a panel at the right of a page or a dialog.
|
|
2
|
+
// The Custom fields manager opens it over its table (`overlay`); a job's
|
|
3
|
+
// panel keeps it as its right column, with tabs (Activity, Review). Closed,
|
|
4
|
+
// it takes no room, so the main area gets the full width.
|
|
5
|
+
//
|
|
6
|
+
// sidecar(h, { label, overlay, escape, tabs, onChange }) answers
|
|
7
|
+
// { element, isOpen, open, show, close, tab, setTabs }:
|
|
8
|
+
// open(id) selects tab `id` (or the first) and draws its `render()`
|
|
9
|
+
// show(node) draws one node with no tabs (the overlay's own form)
|
|
10
|
+
// close() hides it and empties it
|
|
11
|
+
// `tabs` is [{ id, label, icon, render }]; each tab is an icon and one word.
|
|
12
|
+
// `escape` closes it on Esc when no dialog or menu is open over the page.
|
|
13
|
+
// `onChange(id)` hears each open (the tab id) and close (null).
|
|
14
|
+
|
|
15
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
16
|
+
|
|
17
|
+
export function sidecar(h, { label, overlay = false, escape = false, tabs = [], onChange = null } = {}) {
|
|
18
|
+
const element = h('aside', { class: `fx-sidecar${overlay ? ' fx-sidecar--over' : ''}`, 'aria-label': label, hidden: true });
|
|
19
|
+
let list = tabs;
|
|
20
|
+
let current = null;
|
|
21
|
+
|
|
22
|
+
const closeButton = () => h('button', { class: 'fx-sidecar__close', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: () => api.close() }, lucide(h, 'x'));
|
|
23
|
+
|
|
24
|
+
function drawTabs() {
|
|
25
|
+
const head = h('header', { class: 'fx-sidecar__head' },
|
|
26
|
+
h('div', { class: 'fx-sidecar__tabs', role: 'tablist', 'aria-label': label },
|
|
27
|
+
list.map((t) => h('button', {
|
|
28
|
+
class: 'fx-sidecar__tab', type: 'button', role: 'tab', 'data-tab': t.id,
|
|
29
|
+
'aria-selected': String(t.id === current), onclick: () => api.open(t.id),
|
|
30
|
+
}, lucide(h, t.icon), h('span', {}, t.label), t.mark ? h('span', { class: 'fx-sidecar__mark', 'aria-hidden': 'true' }) : null))),
|
|
31
|
+
closeButton());
|
|
32
|
+
const tab = list.find((t) => t.id === current);
|
|
33
|
+
element.replaceChildren(head, h('div', { class: 'fx-sidecar__body', role: 'tabpanel' }, tab?.render?.() ?? null));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const api = {
|
|
37
|
+
element,
|
|
38
|
+
isOpen: () => !element.hidden,
|
|
39
|
+
tab: () => (element.hidden ? null : current),
|
|
40
|
+
open(id = null) {
|
|
41
|
+
current = list.find((t) => t.id === id)?.id ?? list[0]?.id ?? null;
|
|
42
|
+
drawTabs();
|
|
43
|
+
element.hidden = false;
|
|
44
|
+
onChange?.(current);
|
|
45
|
+
},
|
|
46
|
+
show(node) {
|
|
47
|
+
current = null;
|
|
48
|
+
element.replaceChildren(node);
|
|
49
|
+
element.hidden = false;
|
|
50
|
+
},
|
|
51
|
+
close() {
|
|
52
|
+
if (element.hidden) return;
|
|
53
|
+
element.hidden = true;
|
|
54
|
+
element.replaceChildren();
|
|
55
|
+
current = null;
|
|
56
|
+
onChange?.(null);
|
|
57
|
+
},
|
|
58
|
+
// New tabs (a tab's data came in): an open tab draws again.
|
|
59
|
+
setTabs(next) {
|
|
60
|
+
list = next;
|
|
61
|
+
if (!element.hidden && current) drawTabs();
|
|
62
|
+
},
|
|
63
|
+
closeButton,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (escape) {
|
|
67
|
+
document.addEventListener('keydown', (event) => {
|
|
68
|
+
if (event.key === 'Escape' && element.isConnected && !element.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) api.close();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return api;
|
|
72
|
+
}
|
package/server/ui/tip.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Tips: every control that shows only an icon says its name on hover and on
|
|
2
|
+
// keyboard focus, in one small tip beside it. One listener on the document
|
|
3
|
+
// serves the whole app, so a new icon button needs only a name:
|
|
4
|
+
//
|
|
5
|
+
// data-tip="Clear" the tip, when it differs from the spoken name
|
|
6
|
+
// aria-label="Close" an icon-only control's name is its tip
|
|
7
|
+
// title="Search" taken over as the tip (no second, native tip)
|
|
8
|
+
//
|
|
9
|
+
// A control with words on it shows no tip. Clerk's own components keep their
|
|
10
|
+
// own tips. The tip floats on <body>, so a scrolling list cannot cut it off.
|
|
11
|
+
|
|
12
|
+
const INTERACTIVE = 'button, a[href], [role="button"], [role="tab"], [role="radio"], [role="menuitem"], [role="switch"], summary';
|
|
13
|
+
const CANDIDATE = `[data-tip], ${INTERACTIVE.split(', ').map((s) => `${s}[aria-label], ${s}[title]`).join(', ')}, [role="img"][aria-label], [role="img"][title]`;
|
|
14
|
+
|
|
15
|
+
// True when the element shows no word of its own: only icons or a symbol.
|
|
16
|
+
export function iconOnly(text) {
|
|
17
|
+
return !/[\p{L}\p{N}]/u.test(String(text ?? ''));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The tip for this element, or null. `el` needs getAttribute, textContent
|
|
21
|
+
// and closest (a DOM element, or a test's stand-in).
|
|
22
|
+
export function tipText(el) {
|
|
23
|
+
if (!el || el.closest?.('[class*="cl-"]')) return null;
|
|
24
|
+
if (!iconOnly(el.textContent) && !el.getAttribute('data-tip')) return null;
|
|
25
|
+
return el.getAttribute('title') || el.getAttribute('data-tip') || el.getAttribute('aria-label') || null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Marks a control icon-only: its spoken name and its tip. Returns the node.
|
|
29
|
+
export function named(node, label, tip = label) {
|
|
30
|
+
node.setAttribute('aria-label', label);
|
|
31
|
+
node.setAttribute('data-tip', tip);
|
|
32
|
+
node.removeAttribute('title');
|
|
33
|
+
return node;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let bubble = null;
|
|
37
|
+
let anchor = null;
|
|
38
|
+
|
|
39
|
+
function place() {
|
|
40
|
+
if (!bubble || !anchor) return;
|
|
41
|
+
const box = anchor.getBoundingClientRect();
|
|
42
|
+
const tip = bubble.getBoundingClientRect();
|
|
43
|
+
const gap = 6;
|
|
44
|
+
// A folded sidebar's icons take their tip at the right, as in ClickUp.
|
|
45
|
+
if (anchor.dataset.tipSide === 'right') {
|
|
46
|
+
bubble.style.transform = `translate(${Math.round(box.right + gap)}px, ${Math.round(box.top + box.height / 2 - tip.height / 2)}px)`;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const above = box.top - tip.height - gap;
|
|
50
|
+
const top = above >= 4 ? above : box.bottom + gap;
|
|
51
|
+
const left = Math.min(Math.max(4, box.left + box.width / 2 - tip.width / 2), window.innerWidth - tip.width - 4);
|
|
52
|
+
bubble.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function show(el) {
|
|
56
|
+
const text = tipText(el);
|
|
57
|
+
if (!text) return;
|
|
58
|
+
// A native title would show a second tip after a pause, so the tip takes
|
|
59
|
+
// it over (a title set again later wins).
|
|
60
|
+
if (el.hasAttribute('title')) {
|
|
61
|
+
if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', el.getAttribute('title'));
|
|
62
|
+
el.setAttribute('data-tip', el.getAttribute('title'));
|
|
63
|
+
el.removeAttribute('title');
|
|
64
|
+
return show(el);
|
|
65
|
+
}
|
|
66
|
+
if (!bubble) {
|
|
67
|
+
bubble = document.createElement('div');
|
|
68
|
+
bubble.className = 'fx-tip';
|
|
69
|
+
bubble.setAttribute('role', 'tooltip');
|
|
70
|
+
bubble.setAttribute('aria-hidden', 'true');
|
|
71
|
+
}
|
|
72
|
+
bubble.textContent = text;
|
|
73
|
+
anchor = el;
|
|
74
|
+
(el.closest('dialog[open]') ?? document.body).append(bubble);
|
|
75
|
+
place();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hide(el) {
|
|
79
|
+
if (el && el !== anchor) return;
|
|
80
|
+
anchor = null;
|
|
81
|
+
bubble?.remove();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function startTips() {
|
|
85
|
+
document.addEventListener('pointerover', (event) => {
|
|
86
|
+
if (event.pointerType === 'touch') return;
|
|
87
|
+
const el = event.target.closest?.(CANDIDATE);
|
|
88
|
+
if (el && el !== anchor) show(el);
|
|
89
|
+
else if (!el) hide();
|
|
90
|
+
});
|
|
91
|
+
document.addEventListener('focusin', (event) => {
|
|
92
|
+
const el = event.target.closest?.(CANDIDATE);
|
|
93
|
+
if (el && el.matches(':focus-visible')) show(el);
|
|
94
|
+
});
|
|
95
|
+
document.addEventListener('focusout', (event) => hide(event.target.closest?.(CANDIDATE)));
|
|
96
|
+
document.addEventListener('pointerdown', () => hide());
|
|
97
|
+
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') hide(); });
|
|
98
|
+
window.addEventListener('scroll', () => hide(), true);
|
|
99
|
+
}
|