@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
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Capture (/captures): a photo of a handwritten lab form becomes a proposed
|
|
2
|
+
// job change (RFC #357, docs/DATA_MODEL.md "Storage"). One tap opens the
|
|
3
|
+
// phone's own camera. Reading runs on the server, so the person can shoot the
|
|
4
|
+
// next form at once. The tray lists the captures; a row opens the result
|
|
5
|
+
// sheet: the photo, the job, only the fields that change (old, then new;
|
|
6
|
+
// amber when the reader was not sure) and Apply at the foot. On a desk a
|
|
7
|
+
// photo can also be dropped or pasted on the page, and the sheet takes J, K
|
|
8
|
+
// and Enter.
|
|
9
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
10
|
+
import { JOB_FIELDS, SURE_LINE } from '/assets/ui/capture-fields.js';
|
|
11
|
+
import { isPhone } from '/assets/ui/mobile.js';
|
|
12
|
+
import { recordBar } from '/assets/ui/sidecar.js';
|
|
13
|
+
|
|
14
|
+
// The phone sends a smaller copy: about 1,600 px on the long side.
|
|
15
|
+
const LONG_SIDE = 1600;
|
|
16
|
+
const POLL_MS = 3000;
|
|
17
|
+
const POLL_MAX = 40;
|
|
18
|
+
const OFF = 'Capture needs a file store (FLOW_STORAGE).';
|
|
19
|
+
const STATE = { reading: 'Reading', ready: 'Ready', problem: 'Problem', done: 'Done' };
|
|
20
|
+
const TONE = { reading: 'info', ready: 'success', problem: 'warn', done: 'mute' };
|
|
21
|
+
|
|
22
|
+
const valueOf = (capture, key) => capture.read?.fields?.find((f) => f.key === key)?.value || '';
|
|
23
|
+
|
|
24
|
+
// One line for a capture: the practice and the work, else the case number.
|
|
25
|
+
function titleOf(capture) {
|
|
26
|
+
const practice = valueOf(capture, 'practice');
|
|
27
|
+
const work = valueOf(capture, 'work');
|
|
28
|
+
const text = [practice, work].filter(Boolean).join(' · ') || valueOf(capture, 'case_number');
|
|
29
|
+
if (text) return text;
|
|
30
|
+
if (capture.state === 'reading') return 'Reading…';
|
|
31
|
+
const problem = capture.problem ?? '';
|
|
32
|
+
return problem ? problem[0].toUpperCase() + problem.slice(1) : 'Photo';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function shrink(file) {
|
|
36
|
+
try {
|
|
37
|
+
const bitmap = await createImageBitmap(file);
|
|
38
|
+
const scale = Math.min(1, LONG_SIDE / Math.max(bitmap.width, bitmap.height));
|
|
39
|
+
const canvas = document.createElement('canvas');
|
|
40
|
+
canvas.width = Math.round(bitmap.width * scale);
|
|
41
|
+
canvas.height = Math.round(bitmap.height * scale);
|
|
42
|
+
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
43
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
|
|
44
|
+
return blob ?? file;
|
|
45
|
+
} catch {
|
|
46
|
+
// A type the browser cannot draw (HEIC on some browsers) goes as it is.
|
|
47
|
+
return file;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function upload(file) {
|
|
52
|
+
const body = await shrink(file);
|
|
53
|
+
const token = await window.Clerk.session?.getToken();
|
|
54
|
+
const res = await fetch('/api/captures', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
Authorization: `Bearer ${token}`,
|
|
58
|
+
'Content-Type': body.type || 'image/jpeg',
|
|
59
|
+
'Idempotency-Key': crypto.randomUUID(),
|
|
60
|
+
'X-Flow-Capture-Source': isPhone() ? 'phone' : 'desk',
|
|
61
|
+
},
|
|
62
|
+
body,
|
|
63
|
+
});
|
|
64
|
+
const answer = await res.json().catch(() => ({}));
|
|
65
|
+
if (!res.ok) throw new Error(res.status === 404 ? OFF : answer.error || `HTTP ${res.status}`);
|
|
66
|
+
return answer.capture;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The Capture button, on Capture and in the Jobs toolbar: one tap opens the
|
|
70
|
+
// phone's own camera; a desk picks files. `send(files)` uploads each image
|
|
71
|
+
// and calls `added(capture)`, then `done()`.
|
|
72
|
+
export function captureButton(ui, { icon = false, added = () => {}, done = () => {} } = {}) {
|
|
73
|
+
const { h, toast } = ui;
|
|
74
|
+
const picker = h('input', { type: 'file', accept: 'image/*', hidden: true });
|
|
75
|
+
if (isPhone()) picker.setAttribute('capture', 'environment');
|
|
76
|
+
else picker.multiple = true;
|
|
77
|
+
const button = icon
|
|
78
|
+
? h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon fx-capture__shoot', type: 'button', 'aria-label': 'Capture', title: 'Capture', onclick: () => picker.click() }, lucide(h, 'camera'))
|
|
79
|
+
: h('button', { class: 'fx-btn fx-btn--primary fx-capture__shoot', type: 'button', onclick: () => picker.click() }, lucide(h, 'camera'), 'Capture');
|
|
80
|
+
const send = async (files) => {
|
|
81
|
+
for (const file of [...files].filter((f) => f.type.startsWith('image/'))) {
|
|
82
|
+
try {
|
|
83
|
+
added(await upload(file));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
toast(error.message, 'danger', { stay: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
done();
|
|
89
|
+
};
|
|
90
|
+
picker.addEventListener('change', () => {
|
|
91
|
+
const files = [...picker.files];
|
|
92
|
+
picker.value = '';
|
|
93
|
+
send(files);
|
|
94
|
+
});
|
|
95
|
+
return { nodes: [button, picker], send };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function renderCaptures(main, ui) {
|
|
99
|
+
const { h, flow, toast, notice, listHead, listFrame } = ui;
|
|
100
|
+
const shoot = captureButton(ui, {
|
|
101
|
+
added: (capture) => { rows = [capture, ...rows]; draw(); },
|
|
102
|
+
done: () => { polls = 0; load(); },
|
|
103
|
+
});
|
|
104
|
+
const head = listHead('Captures', { actions: shoot.nodes });
|
|
105
|
+
const list = h('ul', { class: 'fx-captures', 'aria-label': 'Captures' });
|
|
106
|
+
const frame = listFrame(main, list);
|
|
107
|
+
main.append(...head.nodes, frame.element);
|
|
108
|
+
|
|
109
|
+
let rows = [];
|
|
110
|
+
let labels = new Map(JOB_FIELDS.map((f) => [f.key, f.label]));
|
|
111
|
+
let polls = 0;
|
|
112
|
+
let timer = null;
|
|
113
|
+
|
|
114
|
+
const draw = () => {
|
|
115
|
+
const ready = rows.filter((c) => c.state === 'ready').length;
|
|
116
|
+
frame.count(ready ? `${ready} ready` : '');
|
|
117
|
+
list.replaceChildren(...(rows.length ? rows.map((capture) => h('li', {},
|
|
118
|
+
h('button', { class: 'fx-captures__row', type: 'button', disabled: capture.state === 'reading', onclick: () => openSheet(capture.id) },
|
|
119
|
+
h('span', { class: 'fx-captures__title', title: titleOf(capture) }, titleOf(capture)),
|
|
120
|
+
h('span', { class: 'fx-status', 'data-tone': TONE[capture.state] }, STATE[capture.state])))) : [h('li', { class: 'fx-empty' }, 'No captures.')]));
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
async function load() {
|
|
124
|
+
try {
|
|
125
|
+
({ captures: rows } = await flow('/api/captures'));
|
|
126
|
+
draw();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const off = error.status === 404;
|
|
129
|
+
list.replaceChildren(h('li', {}, notice(off ? OFF : error.message, off ? 'warn' : 'danger', off ? 'Off.' : null)));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
if (rows.some((c) => c.state === 'reading') && polls < POLL_MAX && frame.element.isConnected) {
|
|
134
|
+
polls += 1;
|
|
135
|
+
timer = setTimeout(load, POLL_MS);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A desk: a photo dropped on the list or pasted on the page is a capture.
|
|
140
|
+
// `main` stays across pages, so the listeners hang on this page's frame.
|
|
141
|
+
frame.element.addEventListener('dragover', (event) => { if (event.dataTransfer?.types.includes('Files')) event.preventDefault(); });
|
|
142
|
+
frame.element.addEventListener('drop', (event) => {
|
|
143
|
+
if (!event.dataTransfer?.files.length) return;
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
shoot.send(event.dataTransfer.files);
|
|
146
|
+
});
|
|
147
|
+
const paste = (event) => {
|
|
148
|
+
if (!frame.element.isConnected) { document.removeEventListener('paste', paste); return; }
|
|
149
|
+
const files = [...(event.clipboardData?.files ?? [])];
|
|
150
|
+
if (files.length && !document.querySelector('dialog[open]')) shoot.send(files);
|
|
151
|
+
};
|
|
152
|
+
document.addEventListener('paste', paste);
|
|
153
|
+
|
|
154
|
+
// The result sheet: a dialog on a desk, the whole screen on a phone (the
|
|
155
|
+
// job panel's layout: record bar, body, the action fixed at the foot).
|
|
156
|
+
async function openSheet(id) {
|
|
157
|
+
let capture;
|
|
158
|
+
try {
|
|
159
|
+
({ capture } = await flow(`/api/captures/${id}`));
|
|
160
|
+
} catch (error) {
|
|
161
|
+
toast(error.message, 'danger');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const element = h('dialog', { class: 'fx-dialog fx-task fx-capture', 'aria-label': 'Capture' });
|
|
165
|
+
const close = () => element.close();
|
|
166
|
+
const at = rows.findIndex((c) => c.id === id);
|
|
167
|
+
// The next capture that has a result; one still reading is skipped.
|
|
168
|
+
const step = (delta) => {
|
|
169
|
+
for (let i = at + delta; i >= 0 && i < rows.length; i += delta) {
|
|
170
|
+
if (rows[i].state === 'reading') continue;
|
|
171
|
+
close();
|
|
172
|
+
openSheet(rows[i].id);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const proposal = capture.proposal ?? { changes: [] };
|
|
177
|
+
const match = capture.match ?? { outcome: 'none', jobs: [] };
|
|
178
|
+
const job = match.jobs.find((j) => j.jobId === proposal.jobId) ?? null;
|
|
179
|
+
const jobLine = job
|
|
180
|
+
? `${job.caseRef ? `Job ${job.caseRef}` : job.jobId} · ${job.clientName ?? ''}`
|
|
181
|
+
: proposal.action === 'create' ? 'New job' : 'Pick a job';
|
|
182
|
+
|
|
183
|
+
const changeRow = (change) => h('div', { class: 'fx-capture__change', 'data-unsure': change.sure != null && change.sure < SURE_LINE ? 'true' : 'false' },
|
|
184
|
+
h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field),
|
|
185
|
+
h('span', { class: 'fx-capture__from' }, change.from || '–'),
|
|
186
|
+
h('span', { class: 'fx-capture__arrow', 'aria-hidden': 'true' }, '→'),
|
|
187
|
+
h('span', { class: 'fx-capture__to' }, change.to));
|
|
188
|
+
|
|
189
|
+
const decide = async (decision) => {
|
|
190
|
+
try {
|
|
191
|
+
await flow(`/api/captures/${id}/decision`, { method: 'POST', body: JSON.stringify({ decision }) });
|
|
192
|
+
toast(decision === 'applied' ? 'Applied.' : 'Discarded.', 'success');
|
|
193
|
+
close();
|
|
194
|
+
load();
|
|
195
|
+
} catch (error) {
|
|
196
|
+
toast(error.message, 'danger', { stay: true });
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const pickJob = async () => {
|
|
201
|
+
const chosen = await ui.pickOne({
|
|
202
|
+
title: 'Other job', label: 'Jobs', find: 'Find', empty: 'No jobs.', confirm: 'Use',
|
|
203
|
+
load: async () => {
|
|
204
|
+
const { jobs } = await flow('/api/invoicing/board');
|
|
205
|
+
const near = new Map(match.jobs.map((j, i) => [j.jobId, i]));
|
|
206
|
+
return jobs.map((j) => ({ ...j, jobId: `${j.sourceSystem}:${j.sourceJobRef}` }))
|
|
207
|
+
.sort((a, b) => (near.get(a.jobId) ?? 99) - (near.get(b.jobId) ?? 99));
|
|
208
|
+
},
|
|
209
|
+
item: (j) => ({ value: j.jobId, title: j.clientName ?? j.jobId, meta: j.caseRef ? `Job ${j.caseRef}` : null }),
|
|
210
|
+
});
|
|
211
|
+
const jobId = chosen?.jobId ?? null;
|
|
212
|
+
if (!jobId) return;
|
|
213
|
+
try {
|
|
214
|
+
await flow(`/api/captures/${id}/proposal`, { method: 'POST', body: JSON.stringify({ ...proposal, action: 'update', jobId }) });
|
|
215
|
+
close();
|
|
216
|
+
openSheet(id);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
toast(error.message, 'danger');
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// A capture the reader could not read: type the fields that find the job.
|
|
223
|
+
const typeIt = () => {
|
|
224
|
+
const inputs = ['case_number', 'practice', 'work'].map((key) => h('input', { class: 'fx-input', name: key, 'aria-label': labels.get(key), placeholder: labels.get(key) }));
|
|
225
|
+
const form = h('form', { class: 'fx-fields', onsubmit: async (event) => {
|
|
226
|
+
event.preventDefault();
|
|
227
|
+
const fields = Object.fromEntries(inputs.map((input) => [input.name, input.value.trim()]).filter(([, v]) => v));
|
|
228
|
+
try {
|
|
229
|
+
await flow(`/api/captures/${id}/match`, { method: 'POST', body: JSON.stringify({ fields }) });
|
|
230
|
+
close();
|
|
231
|
+
openSheet(id);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
toast(error.message, 'danger');
|
|
234
|
+
}
|
|
235
|
+
} }, inputs, h('button', { class: 'fx-btn', type: 'submit' }, 'Find job'));
|
|
236
|
+
return form;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const ready = capture.state === 'ready';
|
|
240
|
+
const actions = ready ? [
|
|
241
|
+
h('button', { class: 'fx-btn', type: 'button', onclick: () => decide('discarded') }, 'Discard'),
|
|
242
|
+
match.jobs.length || proposal.action === 'update' ? h('button', { class: 'fx-btn', type: 'button', onclick: pickJob }, 'Other job') : null,
|
|
243
|
+
h('button', { class: 'fx-btn fx-btn--primary', type: 'button', autofocus: true, disabled: proposal.action === 'update' && !proposal.jobId, onclick: () => decide('applied') }, 'Apply'),
|
|
244
|
+
] : capture.state === 'problem' ? [h('button', { class: 'fx-btn', type: 'button', onclick: () => decide('discarded') }, 'Discard')] : [];
|
|
245
|
+
|
|
246
|
+
element.append(
|
|
247
|
+
recordBar(h, {
|
|
248
|
+
title: jobLine, label: 'Capture', onBack: close,
|
|
249
|
+
prev: { label: 'Previous capture', disabled: at <= 0, onclick: () => step(-1) },
|
|
250
|
+
next: { label: 'Next capture', disabled: at < 0 || at >= rows.length - 1, onclick: () => step(1) },
|
|
251
|
+
}),
|
|
252
|
+
h('div', { class: 'fx-task__grid' }, h('div', { class: 'fx-task__main' },
|
|
253
|
+
h('header', { class: 'fx-task__head' }, h('span'), h('div', { class: 'fx-task__tools' },
|
|
254
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: close }, '✕'))),
|
|
255
|
+
capture.link ? h('img', { class: 'fx-capture__photo', src: capture.link.url, alt: 'Form photo' }) : null,
|
|
256
|
+
h('h2', { class: 'fx-task__title' }, jobLine),
|
|
257
|
+
capture.state === 'problem' ? notice(capture.problem, 'warn', null, { short: capture.problem }) : null,
|
|
258
|
+
capture.state === 'problem' ? typeIt() : null,
|
|
259
|
+
ready && proposal.changes.length ? h('div', { class: 'fx-capture__changes' }, proposal.changes.map(changeRow)) : null,
|
|
260
|
+
ready && !proposal.changes.length ? h('p', { class: 'fx-dim' }, 'No change.') : null,
|
|
261
|
+
h('div', { class: 'fx-task__actions' }, actions))));
|
|
262
|
+
element.addEventListener('click', (event) => { if (event.target === element) close(); });
|
|
263
|
+
// Keys: J next, K previous (not while typing). Apply has the focus, so
|
|
264
|
+
// Enter applies.
|
|
265
|
+
element.addEventListener('keydown', (event) => {
|
|
266
|
+
if (event.target.closest('input, textarea, select') || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
267
|
+
if (event.key !== 'j' && event.key !== 'k') return;
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
step(event.key === 'j' ? 1 : -1);
|
|
270
|
+
});
|
|
271
|
+
element.addEventListener('close', () => element.remove());
|
|
272
|
+
document.body.append(element);
|
|
273
|
+
element.showModal();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const { fields } = await flow('/api/custom-fields');
|
|
278
|
+
labels = new Map([...labels, ...fields.map((f) => [`custom_${f.id}`, f.name])]);
|
|
279
|
+
} catch {
|
|
280
|
+
// The standard labels still show; a custom field shows its key.
|
|
281
|
+
}
|
|
282
|
+
await load();
|
|
283
|
+
}
|
package/server/ui/columns.js
CHANGED
|
@@ -17,15 +17,20 @@
|
|
|
17
17
|
// widths() the saved widths
|
|
18
18
|
// change(patch) saves { columns } or { widths }
|
|
19
19
|
// hidden() [{ id, label }] the + menu lists (no hidden: no +)
|
|
20
|
-
// extra(column, item) menu nodes before Move (
|
|
20
|
+
// extra(column, item) menu nodes before Move (Edit custom field)
|
|
21
|
+
// sorting the heads sort the list (Jobs, Price list), as
|
|
22
|
+
// ClickUp's: { sortOf(columnId) (the sort key, or null),
|
|
23
|
+
// sorts(), change({ sorts }), dirWords(by) }
|
|
21
24
|
// table({ columns, rows, lead, className }), per draw, answers the table:
|
|
22
25
|
// columns the shown columns, in order: { id, label, kind, cell(row),
|
|
23
|
-
// num?, className?,
|
|
26
|
+
// num?, className?, icon?, fixed? }. An icon
|
|
24
27
|
// shows in place of the label (the label is its name). A fixed
|
|
25
28
|
// column keeps its place: no drag, no Move, no Hide.
|
|
26
29
|
// rows the <tbody> nodes (rowCells builds a row's cells)
|
|
27
30
|
// lead <th> nodes before the columns (the tick column)
|
|
28
31
|
//
|
|
32
|
+
// groupHead(h, config) is a grouped list's head row (below).
|
|
33
|
+
//
|
|
29
34
|
// Cells every list shares: dateCell (a short date), personCell (a person's
|
|
30
35
|
// initials) and fieldColumn (one of the lab's custom fields as a column, on
|
|
31
36
|
// Jobs and on Invoices, where it is the invoice's job's value).
|
|
@@ -33,6 +38,7 @@ import { lucide } from '/assets/ui/custom-fields.js';
|
|
|
33
38
|
import { endWidth, LEAD_WIDTH, MAX_WIDTH, minWidth, moveColumn, TAIL_WIDTH, tableWidth, widthOf } from '/assets/ui/job-view.js';
|
|
34
39
|
import { applyPhoneRule, assignPhoneSlots, phoneSlot } from './mobile.js';
|
|
35
40
|
import { initialsOf } from '/assets/ui/person.js';
|
|
41
|
+
import { ICONS, svg } from '/assets/ui/view-bar.js';
|
|
36
42
|
|
|
37
43
|
const fullDate = (iso) => new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
38
44
|
// A list date, short: Today, Tomorrow or Yesterday, else day/month (and
|
|
@@ -132,14 +138,73 @@ export function onRightButton(event, open) {
|
|
|
132
138
|
else open();
|
|
133
139
|
}
|
|
134
140
|
|
|
141
|
+
// A group's head row in a grouped list (Jobs, Price list): a fold button
|
|
142
|
+
// with the group's `badge` (its name and count), then `extra` (a sum). A
|
|
143
|
+
// click on the row folds the group or opens it. `lead` is the cells before
|
|
144
|
+
// the columns (a tick); `span` is how many cells the head covers after them.
|
|
145
|
+
const CHEVRON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3.5 4.5 4.5L6 12.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
146
|
+
export function groupHead(h, { label, badge, open, onFold, lead = [], span, extra = null }) {
|
|
147
|
+
const chevron = h('span', {});
|
|
148
|
+
chevron.innerHTML = CHEVRON;
|
|
149
|
+
const toggle = h('button', {
|
|
150
|
+
type: 'button', class: 'fx-table__fold', 'aria-expanded': String(open), 'aria-label': `${label}. ${open ? 'Fold' : 'Open'} the group`,
|
|
151
|
+
onclick: (event) => { event.stopPropagation(); onFold(); },
|
|
152
|
+
}, chevron.firstChild, badge);
|
|
153
|
+
return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: onFold },
|
|
154
|
+
lead, h('td', { colspan: String(span) }, h('span', { class: 'fx-table__group-row' }, toggle, extra)));
|
|
155
|
+
}
|
|
156
|
+
|
|
135
157
|
export function columnTable(h, config) {
|
|
136
158
|
const menu = columnPopover(h);
|
|
137
159
|
const ids = () => config.order?.() ?? [];
|
|
138
160
|
const widths = () => config.widths?.() ?? {};
|
|
139
161
|
|
|
162
|
+
// A column head's sort button: it shows on hover, or always while it
|
|
163
|
+
// sorts. A click sorts one way, again the other way, and a third time
|
|
164
|
+
// takes the sort off. A right-click on a sorting head opens a small menu:
|
|
165
|
+
// change the direction, or clear the sort.
|
|
166
|
+
const sorting = config.sorting ?? null;
|
|
167
|
+
function sortButton(column) {
|
|
168
|
+
const by = sorting?.sortOf(column.id);
|
|
169
|
+
if (!by) return null;
|
|
170
|
+
const sorts = sorting.sorts();
|
|
171
|
+
const change = (next) => sorting.change({ sorts: next });
|
|
172
|
+
const at = sorts.findIndex((s) => s.by === by);
|
|
173
|
+
const on = at >= 0 ? sorts[at] : null;
|
|
174
|
+
const [up, down] = sorting.dirWords(by);
|
|
175
|
+
const next = () => {
|
|
176
|
+
if (!on) change([{ by, dir: 'asc' }]);
|
|
177
|
+
else if (on.dir === 'asc') change(sorts.map((s, n) => (n === at ? { ...s, dir: 'desc' } : s)));
|
|
178
|
+
else change(sorts.filter((_, n) => n !== at));
|
|
179
|
+
};
|
|
180
|
+
const flip = () => change(sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)));
|
|
181
|
+
const words = on ? `Sorted ${on.dir === 'asc' ? up : down}. Click to ${on.dir === 'asc' ? `sort ${down}` : 'clear the sort'}` : `Sort by ${column.label}`;
|
|
182
|
+
return h('button', {
|
|
183
|
+
type: 'button', class: `fx-th-sort${on ? ' fx-th-sort--on' : ''}`, 'aria-label': words, title: words,
|
|
184
|
+
onclick: (event) => { event.stopPropagation(); next(); },
|
|
185
|
+
oncontextmenu: (event) => {
|
|
186
|
+
if (!on) return;
|
|
187
|
+
event.preventDefault();
|
|
188
|
+
event.stopPropagation();
|
|
189
|
+
onRightButton(event, () => menu.show([
|
|
190
|
+
h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { menu.close(); flip(); } },
|
|
191
|
+
h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${column.label}`), h('span', { class: 'fx-pop__end' }, svg(ICONS.tick))),
|
|
192
|
+
menu.item('ban', 'Clear sort', () => change(sorts.filter((_, n) => n !== at))),
|
|
193
|
+
], event));
|
|
194
|
+
},
|
|
195
|
+
}, lucide(h, on ? (on.dir === 'asc' ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'),
|
|
196
|
+
on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
|
|
197
|
+
}
|
|
198
|
+
|
|
140
199
|
function openMenu(column, event) {
|
|
141
200
|
const parts = [];
|
|
142
201
|
if (column) {
|
|
202
|
+
const by = sorting?.sortOf(column.id);
|
|
203
|
+
if (by) {
|
|
204
|
+
const [up, down] = sorting.dirWords(by);
|
|
205
|
+
parts.push(menu.item('arrow-up-narrow-wide', `Sort: ${up}`, () => sorting.change({ sorts: [{ by, dir: 'asc' }] })),
|
|
206
|
+
menu.item('arrow-down-wide-narrow', `Sort: ${down}`, () => sorting.change({ sorts: [{ by, dir: 'desc' }] })), menu.sep());
|
|
207
|
+
}
|
|
143
208
|
parts.push(...(config.extra?.(column, menu.item) ?? []));
|
|
144
209
|
if (!column.fixed) {
|
|
145
210
|
const order = ids();
|
|
@@ -212,7 +277,7 @@ export function columnTable(h, config) {
|
|
|
212
277
|
class: [c.num ? 'fx-num' : null, c.fixed ? null : 'fx-th--drag'].filter(Boolean).join(' ') || null,
|
|
213
278
|
'data-col': c.id,
|
|
214
279
|
oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => openMenu(c, event)); },
|
|
215
|
-
}, h('span', { class: 'fx-th-cell' }, button, c
|
|
280
|
+
}, h('span', { class: 'fx-th-cell' }, button, sortButton(c)), grip);
|
|
216
281
|
resizer(grip, c, n);
|
|
217
282
|
if (!c.fixed) dragger(button, th, c);
|
|
218
283
|
return th;
|
package/server/ui/flow.css
CHANGED
|
@@ -199,7 +199,9 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
199
199
|
.fx-tip {
|
|
200
200
|
position: fixed; top: 0; left: 0; z-index: 1000; max-width: 260px; padding: 4px 8px;
|
|
201
201
|
border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong);
|
|
202
|
-
font: 500 12px/1.3 var(--t-body); pointer-events: none;
|
|
202
|
+
font: 500 12px/1.3 var(--t-body); pointer-events: none;
|
|
203
|
+
/* A short name keeps one line; a long help text wraps inside the box. */
|
|
204
|
+
width: max-content; overflow-wrap: anywhere;
|
|
203
205
|
box-shadow: var(--t-shadow), 0 4px 12px rgba(0, 0, 0, .12);
|
|
204
206
|
}
|
|
205
207
|
|
|
@@ -382,7 +384,7 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
382
384
|
|
|
383
385
|
/* One integration's page: hero, then the setup beside its facts. */
|
|
384
386
|
.fx-int-page { display: grid; gap: 24px; }
|
|
385
|
-
.fx-int-hero { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
|
387
|
+
.fx-int-hero { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; min-width: 0; }
|
|
386
388
|
.fx-int-hero .fx-title { min-width: 0; font-size: 28px; overflow-wrap: anywhere; }
|
|
387
389
|
.fx-int-hero__actions { position: relative; display: flex; align-items: center; gap: 10px; margin-left: auto; }
|
|
388
390
|
.fx-int-hero__actions .fx-progress { left: auto; right: 0; }
|
|
@@ -399,6 +401,9 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
399
401
|
.fx-int-fold > summary::after { content: ''; width: 8px; height: 8px; margin-left: 2px; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(-45deg); transition: transform 120ms var(--fx-ease); }
|
|
400
402
|
.fx-int-fold[open] > summary::after { transform: rotate(45deg) translateY(-2px); }
|
|
401
403
|
.fx-int-fold:not([open]) { gap: 0; }
|
|
404
|
+
/* A fold inside a section (Settings, Advanced). */
|
|
405
|
+
.fx-int-fold--sub { display: grid; gap: 14px; }
|
|
406
|
+
.fx-int-fold--sub > summary { color: var(--t-dim); font: 600 14.5px/1.3 var(--t-body); }
|
|
402
407
|
.fx-steps { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; counter-reset: step; }
|
|
403
408
|
.fx-steps li { position: relative; padding-left: 38px; min-height: 26px; counter-increment: step; font-size: 14.5px; line-height: 1.55; }
|
|
404
409
|
.fx-steps li::before {
|
|
@@ -561,7 +566,10 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
561
566
|
.fx-totals dd { margin: 0; text-align: right; font-family: var(--t-data, var(--t-body)); font-variant-numeric: tabular-nums; color: var(--t-strong); }
|
|
562
567
|
.fx-totals__grand { padding-top: 8px; border-top: 1px solid var(--fx-line); font-size: 17px; font-weight: 700; color: var(--t-strong) !important; }
|
|
563
568
|
.fx-doc__meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin: 0; padding: 20px; border-bottom: 1px solid var(--fx-line-soft); }
|
|
564
|
-
.fx-doc__meta dd { display: grid; margin: 4px 0 0; color: var(--t-strong); font-weight: 600; }
|
|
569
|
+
.fx-doc__meta dd { display: grid; margin: 4px 0 0; color: var(--t-strong); font-weight: 600; overflow-wrap: anywhere; }
|
|
570
|
+
/* A long value stays in its column: text wraps, an email cuts with "…" (full value on hover). */
|
|
571
|
+
.fx-doc__meta > div, .fx-doc__meta dd > * { min-width: 0; }
|
|
572
|
+
.fx-doc__meta dd > .fx-dim { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
565
573
|
@media (max-width: 1320px) {
|
|
566
574
|
.fx-invoice__grid { grid-template-columns: minmax(0, 1fr); }
|
|
567
575
|
.fx-invoice__aside { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
|
@@ -747,7 +755,7 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
747
755
|
.fx-table__group-row .fx-board__sum { margin-left: 0; }
|
|
748
756
|
/* The Jobs list's group head folds its rows (jobs.js, list): a click on the
|
|
749
757
|
head or its chevron. */
|
|
750
|
-
.fx-
|
|
758
|
+
.fx-table__group { cursor: pointer; }
|
|
751
759
|
.fx-table--jobs .fx-table__group td.fx-tick-cell { padding-right: 0; }
|
|
752
760
|
.fx-table__fold { display: inline-flex; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 2px 0; border: 0; background: none; color: var(--t-dim); font: inherit; cursor: pointer; }
|
|
753
761
|
.fx-table__fold > svg { flex: none; transition: transform .15s; }
|
|
@@ -1064,6 +1072,38 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
|
|
|
1064
1072
|
.fx-task__activity li::after { content: ""; position: absolute; left: 5.5px; top: 16px; bottom: 0; width: 1px; background: var(--fx-line); }
|
|
1065
1073
|
.fx-task__activity li:last-child::after { display: none; }
|
|
1066
1074
|
.fx-task__activity time { display: block; margin-top: 1px; color: var(--t-mute); font-size: 11.5px; }
|
|
1075
|
+
/* Capture (server/ui/captures.js): the tray rows and the result sheet. The
|
|
1076
|
+
sheet reuses the job panel (.fx-task), so a phone gets the same full page. */
|
|
1077
|
+
.fx-captures { margin: 0; padding: 0; list-style: none; }
|
|
1078
|
+
.fx-captures__row {
|
|
1079
|
+
display: flex; align-items: center; gap: 12px; width: 100%; min-height: 56px; padding: 8px 16px;
|
|
1080
|
+
border: 0; border-bottom: 1px solid var(--fx-line-soft); background: transparent; color: var(--t-text);
|
|
1081
|
+
font: inherit; text-align: left; cursor: pointer;
|
|
1082
|
+
}
|
|
1083
|
+
.fx-captures__row:hover:not(:disabled) { background: var(--t-accent-bg); }
|
|
1084
|
+
.fx-captures__row:focus-visible { box-shadow: var(--fx-focus); }
|
|
1085
|
+
.fx-captures__row:disabled { cursor: default; }
|
|
1086
|
+
.fx-captures__title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--t-strong); }
|
|
1087
|
+
.fx-dialog.fx-capture { width: min(720px, calc(100vw - 32px)); }
|
|
1088
|
+
.fx-capture .fx-task__grid { grid-template-columns: minmax(0, 1fr); }
|
|
1089
|
+
.fx-capture__photo { display: block; width: 100%; max-height: 32vh; object-fit: contain; border-radius: var(--t-radius); background: var(--t-bg); }
|
|
1090
|
+
.fx-capture__changes { display: grid; margin-top: 12px; }
|
|
1091
|
+
.fx-capture__change {
|
|
1092
|
+
display: grid; grid-template-columns: minmax(80px, 1fr) minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px;
|
|
1093
|
+
min-height: 44px; padding: 4px 8px; border-bottom: 1px solid var(--fx-line-soft);
|
|
1094
|
+
}
|
|
1095
|
+
.fx-capture__field { color: var(--t-dim); }
|
|
1096
|
+
.fx-capture__from { color: var(--t-dim); text-decoration: line-through; overflow-wrap: anywhere; }
|
|
1097
|
+
.fx-capture__arrow { color: var(--t-dim); }
|
|
1098
|
+
.fx-capture__to { color: var(--t-strong); font-weight: 600; overflow-wrap: anywhere; }
|
|
1099
|
+
.fx-capture__change[data-unsure="true"] { background: var(--t-warn-bg); }
|
|
1100
|
+
.fx-capture__change[data-unsure="true"] .fx-capture__to { color: var(--t-warn); }
|
|
1101
|
+
/* Phones and tablets: Capture and the sheet's controls are thumb sized. */
|
|
1102
|
+
@media (max-width: 1023px) {
|
|
1103
|
+
.fx-dialog.fx-capture .fx-task__actions .fx-btn { min-height: 44px; }
|
|
1104
|
+
.fx-dialog.fx-capture .fx-task__tools .fx-btn--icon { width: 44px; height: 44px; }
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1067
1107
|
/* A record's phone bar and foot. Hidden above a phone, so the desk panel and
|
|
1068
1108
|
the invoice header stay as they are. */
|
|
1069
1109
|
.fx-record, .fx-record__foot { display: none; }
|
|
@@ -1246,6 +1286,9 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1246
1286
|
.fx-setting__head { display: flex; align-items: center; gap: 2px; min-width: 0; }
|
|
1247
1287
|
.fx-setting__head .fx-help { margin: -4px 0; border: 0; background: none; cursor: pointer; }
|
|
1248
1288
|
.fx-setting__hint { margin: 0; color: var(--t-dim); font-size: 13px; line-height: 1.45; }
|
|
1289
|
+
.fx-history__status { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
|
1290
|
+
.fx-history__day { width: 160px; flex: none; }
|
|
1291
|
+
.fx-history__bar { width: 100%; height: 8px; accent-color: var(--t-brand); }
|
|
1249
1292
|
.fx-setting[data-tone="danger"] .fx-setting__hint { color: var(--t-danger); }
|
|
1250
1293
|
.fx-setting__control { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
|
|
1251
1294
|
.fx-setting__control > .fx-input, .fx-setting__control > .fx-select, .fx-setting__stack { width: 320px; max-width: 100%; }
|
|
@@ -1457,6 +1500,8 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1457
1500
|
}
|
|
1458
1501
|
/* Line 2 is 20px; its cells centre on it. */
|
|
1459
1502
|
[data-phone-layout="two-line"] .fx-table td[data-phone-slot="meta"] { display: flex; align-items: center; overflow: visible; }
|
|
1503
|
+
/* Plain text on line 2 ends in "…" instead of running under the next cell. */
|
|
1504
|
+
[data-phone-layout="two-line"] .fx-table td[data-phone-slot="meta"]:not(:has(*)) { display: block; overflow: hidden; line-height: 20px; }
|
|
1460
1505
|
[data-phone-layout="two-line"] td.fx-c-stage,
|
|
1461
1506
|
[data-phone-layout="two-line"] td.fx-c-status { order: 4; color: inherit; }
|
|
1462
1507
|
[data-phone-layout="two-line"] .fx-stage { padding-block: 0; line-height: 16px; }
|
|
@@ -1506,20 +1551,9 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1506
1551
|
.fx-set__title { margin: 8px 0 4px; color: var(--t-strong); font: 600 15px/1.3 var(--t-body); }
|
|
1507
1552
|
.fx-set__hint { color: var(--t-dim); font-size: 13px; line-height: 1.35; }
|
|
1508
1553
|
.fx-set + .fx-set { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--fx-line); }
|
|
1509
|
-
/* Price list (price-list.js):
|
|
1510
|
-
.fx-price { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
|
1511
|
-
.fx-price__head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 8px 16px; }
|
|
1512
|
-
.fx-price__head .fx-search { margin: 0 0 6px; width: 260px; }
|
|
1513
|
-
@media (max-width: 639px) {
|
|
1514
|
-
.fx-price__head .fx-search { width: 100%; }
|
|
1515
|
-
.fx-price__settings .fx-select { min-height: 44px; }
|
|
1516
|
-
}
|
|
1517
|
-
.fx-price__table th { position: sticky; top: 0; z-index: 1; }
|
|
1518
|
-
.fx-price__table td { padding-top: 9px; padding-bottom: 9px; }
|
|
1554
|
+
/* Price list (price-list.js): the codes' table, in the list window. */
|
|
1519
1555
|
.fx-price__table td.fx-num, .fx-price__table th.fx-num { text-align: right; white-space: nowrap; }
|
|
1520
1556
|
.fx-price__code { width: 1%; white-space: nowrap; font-family: var(--t-data); font-size: 13px; font-weight: 600; color: var(--t-strong); }
|
|
1521
|
-
.fx-price__settings { margin-top: 12px; }
|
|
1522
|
-
.fx-ph__actions .fx-tabs__count[data-tone="warn"], .fx-btn .fx-tabs__count[data-tone="warn"] { margin-left: 8px; background: var(--t-warn-bg); color: var(--t-warn); }
|
|
1523
1557
|
/* Price list › Reconcile (price-reconcile.js): Xero › Import's two views. */
|
|
1524
1558
|
.fx-prc .fx-pager { margin-bottom: 4px; }
|
|
1525
1559
|
.fx-prc__finder { display: flex; flex-direction: column; gap: 4px; }
|
|
@@ -1559,6 +1593,40 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1559
1593
|
.fx-cf__choice[aria-checked="true"] { background: var(--fx-hover); color: var(--t-strong); }
|
|
1560
1594
|
.fx-cf__choice > :first-child { justify-self: start; }
|
|
1561
1595
|
.fx-cf__choice .fx-lucide { width: 15px; height: 15px; color: var(--t-accent); }
|
|
1596
|
+
/* Icon picker (kit.js iconPicker): the button shows the icon; the panel is a
|
|
1597
|
+
search box over a grid of icons. A phone gets a sheet at the foot. */
|
|
1598
|
+
.fx-icons { display: inline-flex; }
|
|
1599
|
+
.fx-icons__button {
|
|
1600
|
+
display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 8px 0 10px;
|
|
1601
|
+
border: 1px solid var(--t-control); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong); cursor: pointer;
|
|
1602
|
+
}
|
|
1603
|
+
.fx-icons__button > .fx-lucide { width: 18px; height: 18px; }
|
|
1604
|
+
.fx-icons__button > .fx-lucide + .fx-lucide { width: 14px; height: 14px; color: var(--t-dim); }
|
|
1605
|
+
.fx-icons__button:hover { background: var(--fx-hover); }
|
|
1606
|
+
.fx-icons__button:focus-visible { box-shadow: var(--fx-focus); outline: none; }
|
|
1607
|
+
.fx-pop.fx-icons__panel { width: 316px; max-width: calc(100vw - 16px); padding: 8px; overflow: hidden; }
|
|
1608
|
+
.fx-icons__panel:popover-open { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 8px; }
|
|
1609
|
+
.fx-icons__panel .fx-input { min-height: 32px; font-size: 13px; }
|
|
1610
|
+
.fx-icons__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(36px, 1fr)); gap: 2px; max-height: 248px; overflow-y: auto; }
|
|
1611
|
+
.fx-icons__grid > p { grid-column: 1 / -1; margin: 8px 4px; }
|
|
1612
|
+
.fx-icons__cell {
|
|
1613
|
+
display: inline-grid; place-items: center; aspect-ratio: 1; min-width: 36px; border: 0; border-radius: var(--t-radius);
|
|
1614
|
+
background: none; color: var(--t-text); cursor: pointer;
|
|
1615
|
+
}
|
|
1616
|
+
.fx-icons__cell .fx-lucide { width: 18px; height: 18px; }
|
|
1617
|
+
.fx-icons__cell:hover, .fx-icons__cell:focus-visible { background: var(--fx-hover); color: var(--t-strong); outline: none; }
|
|
1618
|
+
.fx-icons__cell[aria-pressed="true"] { background: var(--t-accent-bg); color: var(--t-accent); box-shadow: inset 0 0 0 1px var(--t-accent-brd, var(--t-accent)); }
|
|
1619
|
+
.fx-icons__foot { display: flex; justify-content: flex-end; }
|
|
1620
|
+
.fx-icons__foot > [hidden] { display: none; }
|
|
1621
|
+
.fx-pop.fx-icons__panel[data-phone-layout="sheet"] {
|
|
1622
|
+
inset: auto 0 0 0; width: 100%; max-width: 100%; max-height: min(70vh, 100%); padding: 12px 12px max(12px, env(safe-area-inset-bottom));
|
|
1623
|
+
border-radius: var(--t-radius-lg) var(--t-radius-lg) 0 0;
|
|
1624
|
+
}
|
|
1625
|
+
.fx-icons__panel[data-phone-layout="sheet"] .fx-input { min-height: 44px; font-size: 16px; }
|
|
1626
|
+
.fx-icons__panel[data-phone-layout="sheet"] .fx-icons__grid { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); max-height: none; }
|
|
1627
|
+
.fx-icons__panel[data-phone-layout="sheet"] .fx-icons__cell { min-width: 44px; }
|
|
1628
|
+
.fx-icons__panel[data-phone-layout="sheet"] .fx-icons__cell .fx-lucide { width: 22px; height: 22px; }
|
|
1629
|
+
.fx-icons__panel[data-phone-layout="sheet"] .fx-icons__foot .fx-btn { min-height: 44px; }
|
|
1562
1630
|
/* Custom fields manager (custom-fields.js), like ClickUp's Custom Field
|
|
1563
1631
|
Manager: a toolbar, then one table of type groups that fold. */
|
|
1564
1632
|
.fx-lucide { display: inline-block; flex: none; width: 16px; height: 16px; background: currentColor; -webkit-mask: var(--icon) center / contain no-repeat; mask: var(--icon) center / contain no-repeat; }
|
|
@@ -1714,6 +1782,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1714
1782
|
.fx-rule__footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
|
|
1715
1783
|
.fx-dialog:has(.fx-rule__dialog) { width: min(680px, calc(100vw - 32px)); }
|
|
1716
1784
|
.fx-rule__dialog .fx-btn--sm { justify-self: start; }
|
|
1785
|
+
.fx-rule__name { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; }
|
|
1717
1786
|
.fx-rule__conditions { display: grid; gap: 8px; }
|
|
1718
1787
|
.fx-rule__condition { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.4fr) auto; gap: 8px; align-items: start; }
|
|
1719
1788
|
.fx-rule__condition + .fx-rule__condition { padding-top: 8px; border-top: 1px solid var(--fx-line); }
|
|
@@ -1830,10 +1899,13 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1830
1899
|
.fx-listhead > .fx-toolbar > .fx-tabs button { padding-block: 9px; }
|
|
1831
1900
|
/* Every control in the toolbar is one height (--fx-control-h). Under a
|
|
1832
1901
|
tab line, the controls keep 8px clear of the line; only the tabs sit on it. */
|
|
1902
|
+
/* A finger needs 44px: on a phone or tablet every list's toolbar controls
|
|
1903
|
+
are that tall (the ListHead card's touch rule). */
|
|
1904
|
+
@media (max-width: 1023px) { .fx-listhead { --fx-control-h: 44px; } }
|
|
1833
1905
|
.fx-listhead .fx-toolbar :is(.fx-btn, .fx-chip, .fx-views__add, .fx-views__tab) { min-height: var(--fx-control-h); }
|
|
1834
1906
|
.fx-listhead .fx-toolbar .fx-btn:not(.fx-btn--sm) { padding-block: 0; font-size: 14px; }
|
|
1835
1907
|
.fx-listhead .fx-toolbar :is(.fx-btn, .fx-chip) { height: var(--fx-control-h); }
|
|
1836
|
-
.fx-listhead .fx-toolbar .fx-find__button { width: var(--fx-control-h); height: var(--fx-control-h); }
|
|
1908
|
+
.fx-listhead .fx-toolbar :is(.fx-find__button, .fx-btn--icon) { width: var(--fx-control-h); height: var(--fx-control-h); }
|
|
1837
1909
|
.fx-listhead .fx-toolbar .fx-find__input { height: var(--fx-control-h); }
|
|
1838
1910
|
.fx-listhead .fx-toolbar .fx-seg { padding: 2px; }
|
|
1839
1911
|
.fx-listhead .fx-toolbar .fx-seg__item { min-height: calc(var(--fx-control-h) - 4px); }
|
|
@@ -1846,6 +1918,9 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1846
1918
|
.fx-listhead[data-sticky] { position: static; margin-top: 0; padding-top: 0; }
|
|
1847
1919
|
.fx-listhead__edge + .fx-listhead[data-sticky] { margin-top: -8px; }
|
|
1848
1920
|
.fx-alerts { margin-bottom: 4px; }
|
|
1921
|
+
/* The tools have their own row under the tabs: no gap under them, so the
|
|
1922
|
+
44px controls still leave seven rows on a small phone. */
|
|
1923
|
+
.fx-listhead > .fx-toolbar:not(.fx-toolbar--plain) > :not(.fx-tabs) { margin-bottom: 0; }
|
|
1849
1924
|
}
|
|
1850
1925
|
|
|
1851
1926
|
/* The bulk action bar (bulk-bar.js): floats at the foot while rows are
|
|
@@ -18,6 +18,9 @@ const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft', pending:
|
|
|
18
18
|
const NO_VALUE = ['is_empty', 'is_not_empty', 'has_number', 'number_in_words', 'one_word'];
|
|
19
19
|
const MANY = ['is_one_of', 'is_not_one_of'];
|
|
20
20
|
const TYPED = ['contains', 'does_not_contain', 'starts_with', 'does_not_start_with'];
|
|
21
|
+
// A rule's icon: its own (rule.icon), else the mark of what it does.
|
|
22
|
+
const KIND_MARKS = { pending: 'flag', wait: 'clock', skip: 'ban' };
|
|
23
|
+
export const ruleMark = (rule) => rule.icon ?? KIND_MARKS[rule.action.kind] ?? 'list-checks';
|
|
21
24
|
|
|
22
25
|
// A rule as one short line: "Job age (days) is more than 90 · Wait for a person".
|
|
23
26
|
export function ruleSummary(rule, meta) {
|
|
@@ -42,7 +45,7 @@ export function ruleSummary(rule, meta) {
|
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
export function rulesSection(ui, title = 'Rules') {
|
|
45
|
-
const { h, flow, toast, notice, dialog, me, settingRow } = ui;
|
|
48
|
+
const { h, flow, toast, notice, dialog, me, settingRow, iconPicker } = ui;
|
|
46
49
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
47
50
|
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
|
|
48
51
|
let meta;
|
|
@@ -153,6 +156,10 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
153
156
|
const action = h('select', { class: 'fx-select', 'aria-label': 'Then' },
|
|
154
157
|
Object.entries(ACTION_LABELS).map(([value, label]) => h('option', { value }, label)));
|
|
155
158
|
action.value = draft?.action.kind ?? 'wait';
|
|
159
|
+
// With no icon of its own, the rule shows the mark of its Then.
|
|
160
|
+
const kindMark = () => ruleMark({ action: { kind: action.value } });
|
|
161
|
+
const icon = iconPicker({ value: draft?.icon ?? null, fallback: kindMark(), label: 'Icon' });
|
|
162
|
+
action.addEventListener('change', () => icon.fallback(kindMark()));
|
|
156
163
|
// A pending rule's note: what a person must fix. It shows on the draft.
|
|
157
164
|
const note = h('input', {
|
|
158
165
|
class: 'fx-input', maxlength: '200', value: draft?.action.note ?? '', 'aria-label': 'Note',
|
|
@@ -191,7 +198,7 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
191
198
|
const data = await dialog({
|
|
192
199
|
title: rule ? 'Edit rule' : 'New rule',
|
|
193
200
|
body: h('div', { class: 'fx-fields fx-rule__dialog' },
|
|
194
|
-
label('Name', name),
|
|
201
|
+
h('div', { class: 'fx-rule__name' }, label('Icon', icon.element), label('Name', name)),
|
|
195
202
|
label('When', h('span', { class: 'fx-dim' }, 'A job is ready to invoice. A pending rule also checks its draft and the Reference.')),
|
|
196
203
|
label('If all of these match', list, more),
|
|
197
204
|
label('Then', action),
|
|
@@ -207,6 +214,7 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
207
214
|
if (!data) return null;
|
|
208
215
|
const payload = {
|
|
209
216
|
name: name.value,
|
|
217
|
+
icon: icon.value(),
|
|
210
218
|
conditions: editors.map((e) => e.read()),
|
|
211
219
|
action: action.value === 'pending' ? { kind: 'pending', note: note.value } : { kind: action.value },
|
|
212
220
|
};
|