@arcforgelabs/flow 2026.10.40 → 2026.10.41
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/core/job-match.js +2 -0
- package/server/ui/flow.css +24 -38
- package/server/ui/invoicing.js +15 -53
- package/server/ui/jobs.js +15 -172
- package/server/ui/reconcile.js +279 -95
- package/server/ui/routes.js +1 -1
- package/server/ui/view-bar.js +236 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.41",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.41",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/core/job-match.js
CHANGED
|
@@ -97,6 +97,8 @@ export async function listReconcile(db, organisationId) {
|
|
|
97
97
|
practitioner: billingObligations.practitioner,
|
|
98
98
|
jobDate: billingObligations.jobDate,
|
|
99
99
|
totalCents: billingObligations.totalCents,
|
|
100
|
+
// An unclear accounting match: "Make invoice" settles it first.
|
|
101
|
+
unclear: sql`${billingObligations.accountingState} = 'ambiguous'`.mapWith(Boolean),
|
|
100
102
|
possible: sql`(select count(*) from invoices i
|
|
101
103
|
where i.organisation_id = ${billingObligations.organisationId} and i.contact_id = ${billingObligations.contactId}
|
|
102
104
|
and i.status <> 'void' and i.total_cents = ${billingObligations.totalCents}
|
package/server/ui/flow.css
CHANGED
|
@@ -387,9 +387,6 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
387
387
|
.fx-tabs button[aria-selected="true"] { color: var(--t-strong); border-bottom-color: var(--t-brand); }
|
|
388
388
|
.fx-tabs__count { padding: 0 7px; border-radius: 999px; background: var(--fx-hover); color: var(--t-dim); font-size: 12px; line-height: 20px; }
|
|
389
389
|
.fx-tabs button[data-tone="danger"] .fx-tabs__count { background: var(--t-danger-bg); color: var(--t-danger); }
|
|
390
|
-
/* Invoices: the Send all ready bar above the Ready list. */
|
|
391
|
-
.fx-sendbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 12px; padding: 12px 16px; border: 1px solid var(--fx-line); border-radius: 8px; background: var(--t-paper); margin-top: 12px; }
|
|
392
|
-
.fx-sendbar[hidden] { display: none; }
|
|
393
390
|
|
|
394
391
|
/* Quiet alerts (server/ui/alerts.js): one slim line under the page header,
|
|
395
392
|
only while something on the page needs this person. */
|
|
@@ -409,9 +406,7 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
409
406
|
.fx-alerts__text { flex-basis: calc(100% - 52px); }
|
|
410
407
|
.fx-alerts__action, .fx-alerts__more { order: 2; }
|
|
411
408
|
}
|
|
412
|
-
.fx-sendbar p { margin: 0; }
|
|
413
409
|
.fx-problem { color: var(--t-danger); }
|
|
414
|
-
.fx-sendbar__actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
415
410
|
.fx-tick-cell { width: 40px; }
|
|
416
411
|
.fx-tick { width: 16px; height: 16px; accent-color: var(--t-brand); cursor: pointer; }
|
|
417
412
|
.fx-search { width: min(320px, 100%); margin-bottom: 8px; }
|
|
@@ -705,26 +700,15 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
705
700
|
.fx-task__none { display: inline-flex; align-items: center; gap: 6px; color: var(--t-warn); }
|
|
706
701
|
.fx-task__none svg { width: 16px; height: 16px; }
|
|
707
702
|
|
|
708
|
-
/* Reconcile: the
|
|
709
|
-
|
|
710
|
-
.fx-rec
|
|
711
|
-
.fx-
|
|
712
|
-
.fx-
|
|
713
|
-
.fx-
|
|
714
|
-
.fx-
|
|
715
|
-
.fx-rec__search { width: 100%; min-width: 0; }
|
|
703
|
+
/* Reconcile (reconcile.js): the list, as Jobs and Invoices; one gap, as an
|
|
704
|
+
invoice opened from Invoices (the pager on top). */
|
|
705
|
+
.fx-rec { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
|
706
|
+
.fx-rec .fx-lucide { width: 16px; height: 16px; }
|
|
707
|
+
.fx-rec__gap { max-width: 1180px; }
|
|
708
|
+
.fx-rec__gap .fx-toolbar .fx-rows__title { margin: 0; }
|
|
709
|
+
.fx-rec__gap .fx-ph__actions .fx-btn, .fx-rec__acts .fx-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
|
716
710
|
.fx-rec__results { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
|
717
|
-
.fx-
|
|
718
|
-
.fx-rec__item:hover { background: var(--fx-hover); }
|
|
719
|
-
.fx-rec__item[aria-current="true"] { border-left-color: var(--t-accent); background: var(--t-s1); }
|
|
720
|
-
.fx-rec__item-top { display: flex; justify-content: space-between; gap: 8px; }
|
|
721
|
-
.fx-rec__item .fx-num { font-family: var(--t-data); font-variant-numeric: tabular-nums; }
|
|
722
|
-
.fx-rec__detail { display: flex; flex-direction: column; gap: 16px; min-width: 0; padding: 20px 24px; }
|
|
723
|
-
.fx-rec__head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
|
|
724
|
-
.fx-rec__head > div { display: grid; gap: 2px; }
|
|
725
|
-
.fx-rec__back { display: none; color: var(--t-accent); text-decoration: none; font-size: 13px; }
|
|
726
|
-
.fx-rec__title { margin: 0; font-family: var(--t-display); font-weight: var(--t-display-weight); color: var(--t-strong); font-size: 24px; }
|
|
727
|
-
.fx-rec__table { display: grid; grid-template-columns: 110px minmax(150px, 1fr) repeat(var(--cols), minmax(170px, 1fr)); border: 1px solid var(--fx-line); border-radius: var(--t-radius); overflow-x: auto; }
|
|
711
|
+
.fx-rec__table { display: grid; grid-template-columns: 110px minmax(150px, 1fr) repeat(var(--cols), minmax(170px, 1fr)); border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); overflow-x: auto; }
|
|
728
712
|
.fx-rec__row { display: contents; }
|
|
729
713
|
.fx-rec__row > span { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border-top: 1px solid var(--fx-line-soft); font-size: 13.5px; color: var(--t-strong); }
|
|
730
714
|
.fx-rec__row:first-child > span { border-top: 0; background: var(--t-s1); }
|
|
@@ -738,8 +722,6 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
738
722
|
[data-mark="same"] > .fx-rec__mark { color: var(--t-success); }
|
|
739
723
|
[data-mark="differs"], [data-mark="differs"] > .fx-rec__mark { color: var(--t-warn); }
|
|
740
724
|
.fx-rec__best { color: var(--t-success); font-size: 12px; font-weight: 700; }
|
|
741
|
-
.fx-rec__foot { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
742
|
-
.fx-rec__foot-acts { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
743
725
|
.fx-rec__cards { display: none; }
|
|
744
726
|
.fx-rec__card { display: flex; flex-direction: column; gap: 6px; padding: 12px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); font-size: 13.5px; color: var(--t-strong); }
|
|
745
727
|
.fx-rec__card.fx-rec__job { border-color: transparent; }
|
|
@@ -747,20 +729,17 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
747
729
|
.fx-rec__card-top { display: flex; justify-content: space-between; gap: 8px; }
|
|
748
730
|
.fx-rec__label { color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
749
731
|
.fx-rec__checks { display: flex; flex-wrap: wrap; gap: 4px 12px; }
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
.fx-rec__back { display: inline; }
|
|
756
|
-
}
|
|
732
|
+
.fx-rec-list tbody tr { cursor: pointer; }
|
|
733
|
+
.fx-rec-list__kind { width: 36px; color: var(--t-dim); }
|
|
734
|
+
.fx-rec-list__mid { width: 72px; text-align: center; white-space: nowrap; }
|
|
735
|
+
.fx-rec__possible { display: inline-flex; align-items: center; gap: 4px; color: var(--t-dim); font-variant-numeric: tabular-nums; }
|
|
736
|
+
.fx-rec__possible[data-state="some"] { color: var(--t-warn); }
|
|
757
737
|
@media (max-width: 820px) {
|
|
758
|
-
.fx-rec__detail { padding: 16px; }
|
|
759
738
|
.fx-rec__table { display: none; }
|
|
760
739
|
.fx-rec__cards { display: flex; flex-direction: column; gap: 12px; }
|
|
761
|
-
.fx-rec__cards .fx-btn, .fx-
|
|
762
|
-
.fx-
|
|
763
|
-
.fx-
|
|
740
|
+
.fx-rec__cards .fx-btn, .fx-rec__gap .fx-ph__actions .fx-btn { min-height: 44px; }
|
|
741
|
+
.fx-rec__gap .fx-ph__actions { width: 100%; }
|
|
742
|
+
.fx-rec__gap .fx-ph__actions .fx-btn { flex: 1 1 auto; }
|
|
764
743
|
}
|
|
765
744
|
.fx-task__custom { margin-top: 24px; }
|
|
766
745
|
.fx-task__custom h3 { margin: 0 0 4px; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
@@ -1293,7 +1272,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1293
1272
|
Narrow, the app bar (60px) sits above it. */
|
|
1294
1273
|
.fx-listhead { position: sticky; top: 0; z-index: 5; margin: -12px calc(-1 * clamp(16px, 4vw, 48px)) 0; padding: 12px clamp(16px, 4vw, 48px) 0; background: var(--t-bg); transition: box-shadow .15s; }
|
|
1295
1274
|
.fx-listhead[data-stuck] { box-shadow: 0 10px 12px -12px rgba(0, 0, 0, .45); }
|
|
1296
|
-
.fx-listhead .fx-sendbar { margin-bottom: 12px; }
|
|
1297
1275
|
.fx-listhead__edge { height: 0; margin-bottom: -20px; }
|
|
1298
1276
|
@media (max-width: 820px) { .fx-listhead { top: 60px; } }
|
|
1299
1277
|
/* On a phone the head would hide too many rows: it scrolls away. */
|
|
@@ -1389,3 +1367,11 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1389
1367
|
}
|
|
1390
1368
|
.fx-xi-count { font-size: 18px; }
|
|
1391
1369
|
.fx-xi-list td.fx-num:not(.fx-strong) { text-align: left; }
|
|
1370
|
+
|
|
1371
|
+
/* A list's own search (view-bar.js findBox): an icon that opens into a box. */
|
|
1372
|
+
.fx-find { display: inline-flex; align-items: center; }
|
|
1373
|
+
.fx-find__button { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; }
|
|
1374
|
+
.fx-find__button .fx-lucide { width: 15px; height: 15px; }
|
|
1375
|
+
.fx-find__input { display: none; width: min(240px, 60vw); height: 32px; padding: 0 10px; }
|
|
1376
|
+
.fx-find--open .fx-find__input { display: block; }
|
|
1377
|
+
.fx-find--open .fx-find__button { display: none; }
|
package/server/ui/invoicing.js
CHANGED
|
@@ -10,6 +10,7 @@ import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
|
10
10
|
import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
|
|
11
11
|
import { caseNumber } from '/assets/ui/job-board.js';
|
|
12
12
|
import { jobChangeBanner } from '/assets/ui/job-change.js';
|
|
13
|
+
import { findBox } from '/assets/ui/view-bar.js';
|
|
13
14
|
|
|
14
15
|
// The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
|
|
15
16
|
// person must look), Ready (checks pass), then the accounting system's
|
|
@@ -118,17 +119,24 @@ export async function renderInvoiceList(main, ui) {
|
|
|
118
119
|
sessionStorage.removeItem(NAV_BACK);
|
|
119
120
|
const state = { status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set() };
|
|
120
121
|
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
|
|
121
|
-
|
|
122
|
-
const
|
|
122
|
+
let timer;
|
|
123
|
+
const find = findBox(h, {
|
|
124
|
+
label: 'Find a number, client or reference', value: state.q,
|
|
125
|
+
oninput: (words) => {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
timer = setTimeout(() => { state.q = words.trim(); load(); }, 200);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
123
130
|
const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
124
131
|
const bulk = bulkBar(ui);
|
|
125
132
|
const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
|
|
126
133
|
let body = null;
|
|
127
134
|
const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
|
|
128
135
|
const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
|
|
129
|
-
// The tabs
|
|
130
|
-
//
|
|
131
|
-
|
|
136
|
+
// The tabs and search stay at the top while the rows scroll up under them;
|
|
137
|
+
// the bulk bar floats at the foot (ClickUp's layout). Sending is a bulk
|
|
138
|
+
// action: tick rows (the head tick takes every row shown), then Send.
|
|
139
|
+
const head = h('div', { class: 'fx-listhead' }, h('div', { class: 'fx-toolbar' }, tabs, find.element));
|
|
132
140
|
const edge = h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' });
|
|
133
141
|
main.append(
|
|
134
142
|
pageHeader('Invoices', {
|
|
@@ -144,56 +152,11 @@ export async function renderInvoiceList(main, ui) {
|
|
|
144
152
|
new IntersectionObserver(([entry]) => head.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${head.offsetTop > 0 && window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1}px 0px 0px 0px` }).observe(edge);
|
|
145
153
|
}
|
|
146
154
|
|
|
147
|
-
let timer;
|
|
148
|
-
search.addEventListener('input', () => {
|
|
149
|
-
clearTimeout(timer);
|
|
150
|
-
timer = setTimeout(() => { state.q = search.value.trim(); load(); }, 200);
|
|
151
|
-
});
|
|
152
155
|
|
|
153
156
|
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
154
|
-
|
|
155
|
-
const el = h('button', {
|
|
156
|
-
class: `fx-btn fx-btn--${tone}`, type: 'button',
|
|
157
|
-
onclick: async () => {
|
|
158
|
-
el.disabled = true;
|
|
159
|
-
try {
|
|
160
|
-
const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload()) });
|
|
161
|
-
toast(...done(res));
|
|
162
|
-
state.ticked.clear();
|
|
163
|
-
seen.clear();
|
|
164
|
-
await load();
|
|
165
|
-
alerts.refresh();
|
|
166
|
-
} catch (error) {
|
|
167
|
-
toast(error.message, 'danger');
|
|
168
|
-
el.disabled = false;
|
|
169
|
-
}
|
|
170
|
-
},
|
|
171
|
-
}, label);
|
|
172
|
-
return el;
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
// The bar above the list: what a person can do with this view.
|
|
157
|
+
// The bulk bar for the ticked rows: what a person can do with this view.
|
|
176
158
|
function drawBar() {
|
|
177
|
-
const to = body.sendsTo;
|
|
178
159
|
const ticked = [...state.ticked];
|
|
179
|
-
const parts = [];
|
|
180
|
-
if (state.status === 'ready') {
|
|
181
|
-
parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
|
|
182
|
-
const buttons = [];
|
|
183
|
-
if (context.canIssue && body.counts.ready > 0) {
|
|
184
|
-
buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Approve all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
|
|
185
|
-
(res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
|
|
186
|
-
}
|
|
187
|
-
if (buttons.length) parts.push(h('div', { class: 'fx-sendbar__actions' }, buttons));
|
|
188
|
-
} else if (state.status === 'pending') {
|
|
189
|
-
const failed = body.invoices.filter((i) => i.status === 'issued' && i.problems.some((p) => p.code === 'send_failed' || p.code?.startsWith('xero_'))).length;
|
|
190
|
-
if (context.canIssue && to && failed > 0) {
|
|
191
|
-
parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, 'invoice', 'invoices')} did not reach ${to}. Fix the cause, then send again.`));
|
|
192
|
-
parts.push(action('Send again', 'send-again', () => ({}), (res) => [`${res.queued} on the way to ${to} again.`, 'success']));
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
bar.replaceChildren(...parts);
|
|
196
|
-
bar.hidden = !parts.length;
|
|
197
160
|
bulk.draw({ count: ticked.length, onClear: () => setTicks([]), ...bulkActions(ticked) });
|
|
198
161
|
}
|
|
199
162
|
|
|
@@ -228,7 +191,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
228
191
|
const apply = [];
|
|
229
192
|
if (state.status === 'ready' && context.canIssue) {
|
|
230
193
|
status.push({
|
|
231
|
-
icon: to ? 'send' : 'circle-check-big', label: to ?
|
|
194
|
+
icon: to ? 'send' : 'circle-check-big', label: to ? `Send to ${to}` : 'Approve', primary: true,
|
|
232
195
|
title: to ? `Send to ${to}` : 'Approve',
|
|
233
196
|
run: async () => {
|
|
234
197
|
const ok = await ui.dialog({
|
|
@@ -352,7 +315,6 @@ export async function renderInvoiceList(main, ui) {
|
|
|
352
315
|
draw(seen.get(key));
|
|
353
316
|
} else if (body) {
|
|
354
317
|
drawTabs(body.counts);
|
|
355
|
-
bar.hidden = true;
|
|
356
318
|
host.dataset.stale = '';
|
|
357
319
|
}
|
|
358
320
|
host.setAttribute('aria-busy', 'true');
|
package/server/ui/jobs.js
CHANGED
|
@@ -19,9 +19,10 @@ import {
|
|
|
19
19
|
caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
|
|
20
20
|
} from '/assets/ui/job-board.js';
|
|
21
21
|
import {
|
|
22
|
-
COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS,
|
|
22
|
+
COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
|
|
23
23
|
normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
|
|
24
24
|
} from '/assets/ui/job-view.js';
|
|
25
|
+
import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
|
|
25
26
|
|
|
26
27
|
const LAYOUT_KEY = 'flow_jobs_layout';
|
|
27
28
|
const FOLDED_KEY = 'flow_jobs_folded';
|
|
@@ -84,19 +85,9 @@ function saveFolded(folded) {
|
|
|
84
85
|
try { localStorage.setItem(FOLDED_KEY, JSON.stringify(folded)); } catch { /* a convenience only */ }
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
// The local calendar day, as yyyy-mm-dd, to mark late jobs.
|
|
88
|
-
const today = () => {
|
|
89
|
-
const now = new Date();
|
|
90
|
-
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
91
|
-
};
|
|
92
88
|
|
|
93
89
|
const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6v3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r=".9" fill="currentColor"/></svg>';
|
|
94
90
|
const PERSON_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="5.5" r="3" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 14c.6-2.8 2.8-4.3 5.5-4.3s4.9 1.5 5.5 4.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
|
|
95
|
-
const GROUP_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.8 14.5 5 8 8.2 1.5 5z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="m1.5 8.2 6.5 3.2 6.5-3.2M1.5 11.2 8 14.4l6.5-3.2" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>';
|
|
96
|
-
const SORT_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m4.5 6 3.5-3.5L11.5 6M4.5 10 8 13.5l3.5-3.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
97
|
-
const COLUMNS_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M6 2.5v11M10 2.5v11" stroke="currentColor" stroke-width="1.3"/></svg>';
|
|
98
|
-
const FILTER_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 3.5h12M4.5 8h7M7 12.5h2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>';
|
|
99
|
-
const TICK_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m3 8.5 3.2 3L13 4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
100
91
|
const CHEVRON_ICON = '<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>';
|
|
101
92
|
const CALENDAR_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
|
|
102
93
|
|
|
@@ -120,7 +111,7 @@ export async function renderJobs(main, ui) {
|
|
|
120
111
|
ticked: new Set(), folded: readFolded(),
|
|
121
112
|
};
|
|
122
113
|
const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
|
|
123
|
-
const
|
|
114
|
+
const find = findBox(h, { label: 'Find a case, client or practitioner', value: asked, oninput: (words) => { state.q = words.trim(); draw(); } });
|
|
124
115
|
const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
|
|
125
116
|
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
|
|
126
117
|
// In the list, ticked rows get the same floating bar as Invoices.
|
|
@@ -128,7 +119,7 @@ export async function renderJobs(main, ui) {
|
|
|
128
119
|
main.classList.add('fx-main--wide');
|
|
129
120
|
main.append(
|
|
130
121
|
pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
|
|
131
|
-
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' },
|
|
122
|
+
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, find.element, toggle)),
|
|
132
123
|
host,
|
|
133
124
|
bulk.element);
|
|
134
125
|
|
|
@@ -137,7 +128,6 @@ export async function renderJobs(main, ui) {
|
|
|
137
128
|
onclick: () => { saveLayout(id); change({ layout: id }); drawToggle(); },
|
|
138
129
|
}, label)));
|
|
139
130
|
drawToggle();
|
|
140
|
-
search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
|
|
141
131
|
|
|
142
132
|
const problemsOf = (job) => fixes(job.obligation);
|
|
143
133
|
const jobKey = (job) => `${job.sourceSystem}\u0000${job.sourceJobRef}`;
|
|
@@ -177,45 +167,8 @@ export async function renderJobs(main, ui) {
|
|
|
177
167
|
}, 500);
|
|
178
168
|
}
|
|
179
169
|
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
// panel is a popover, so a click outside or Escape closes it.
|
|
183
|
-
let menuCount = 0;
|
|
184
|
-
function menu(icon, build, { rows = false, clearLabel = 'Clear' } = {}) {
|
|
185
|
-
const id = `fx-jobs-menu-${++menuCount}`;
|
|
186
|
-
const panel = h('div', { class: `fx-pop${rows ? ' fx-pop--rows' : ''}`, id, popover: 'auto', role: rows ? 'dialog' : 'menu' });
|
|
187
|
-
const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': rows ? 'dialog' : 'menu', 'aria-expanded': 'false' });
|
|
188
|
-
const clear = h('button', { type: 'button', class: 'fx-chip__clear', 'aria-label': clearLabel, title: clearLabel, hidden: true }, lucide(h, 'x'));
|
|
189
|
-
const wrap = h('span', { class: 'fx-chipset' }, button, clear);
|
|
190
|
-
let onClear = null;
|
|
191
|
-
clear.addEventListener('click', () => onClear?.());
|
|
192
|
-
const fill = () => panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
|
|
193
|
-
panel.addEventListener('toggle', (event) => {
|
|
194
|
-
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
195
|
-
if (event.newState !== 'open') return;
|
|
196
|
-
fill();
|
|
197
|
-
const at = button.getBoundingClientRect();
|
|
198
|
-
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
199
|
-
panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
200
|
-
});
|
|
201
|
-
return {
|
|
202
|
-
node: [wrap, panel],
|
|
203
|
-
wrap,
|
|
204
|
-
fill: () => { if (panel.matches(':popover-open')) fill(); },
|
|
205
|
-
set(label, on, clearIt = null) {
|
|
206
|
-
button.replaceChildren(svg(icon), h('span', {}, label));
|
|
207
|
-
wrap.classList.toggle('fx-chipset--on', on);
|
|
208
|
-
button.classList.toggle('fx-chip--on', on);
|
|
209
|
-
onClear = clearIt;
|
|
210
|
-
clear.hidden = !clearIt;
|
|
211
|
-
},
|
|
212
|
-
};
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// A choice in a menu: a row with a tick when it is chosen.
|
|
216
|
-
const option = (text, checked, onclick, role = 'menuitemradio') => h('button', {
|
|
217
|
-
type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
|
|
218
|
-
}, h('span', { class: 'fx-pop__tick' }, checked ? svg(TICK_ICON) : null), h('span', {}, text));
|
|
170
|
+
// The chips (view-bar.js), shared with every list page.
|
|
171
|
+
const { menu, option, title, choose, bin, addButton, rowsFoot, clearAll, filterMenu: buildFilterMenu } = viewBar(h);
|
|
219
172
|
const DATE_WORDS = ['Oldest first', 'Newest first'];
|
|
220
173
|
const NUMBER_WORDS = ['Low to high', 'High to low'];
|
|
221
174
|
const DIR_WORDS = { total: NUMBER_WORDS, due: DATE_WORDS, ordered: DATE_WORDS, shipped: DATE_WORDS };
|
|
@@ -223,19 +176,6 @@ export async function renderJobs(main, ui) {
|
|
|
223
176
|
const dirWords = (by) => DIR_WORDS[by] ?? TYPE_WORDS[fieldById(fieldIdOf(by))?.type] ?? ['A to Z', 'Z to A'];
|
|
224
177
|
const fieldById = (id) => state.fields.find((f) => f.id === id) ?? null;
|
|
225
178
|
|
|
226
|
-
// The parts of a row panel (Group by, Sort, Filters): a title, rows of
|
|
227
|
-
// selects with a bin at the end, and a foot with "Add" and "Clear all".
|
|
228
|
-
const title = (text) => h('p', { class: 'fx-rows__title' }, text);
|
|
229
|
-
const choose = (label, value, options, onchange, extra = {}) => h('select', {
|
|
230
|
-
class: 'fx-select fx-select--sm', 'aria-label': label, onchange: (event) => onchange(event.target.value), ...extra,
|
|
231
|
-
}, options.map((o) => (o.group
|
|
232
|
-
? (o.options.length ? h('optgroup', { label: o.group }, o.options.map((x) => h('option', { value: x.value, selected: x.value === value }, x.label))) : null)
|
|
233
|
-
: h('option', { value: o.value, selected: o.value === value, disabled: o.disabled }, o.label))));
|
|
234
|
-
const bin = (label, onclick) => h('button', { type: 'button', class: 'fx-rows__bin', 'aria-label': label, title: label, onclick }, lucide(h, 'trash-2'));
|
|
235
|
-
const addButton = (text, onclick) => h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm fx-rows__add', onclick }, lucide(h, 'plus'), text);
|
|
236
|
-
const rowsFoot = (...parts) => h('div', { class: 'fx-rows__foot' }, ...parts);
|
|
237
|
-
const clearAll = (onclick) => h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick }, 'Clear all');
|
|
238
|
-
|
|
239
179
|
// ── Group by ──
|
|
240
180
|
// One row: what to group by, the group order, and a bin that takes the
|
|
241
181
|
// groups away (the list only: the board always has lanes).
|
|
@@ -243,7 +183,7 @@ export async function renderJobs(main, ui) {
|
|
|
243
183
|
...Object.entries(GROUPS).filter(([id]) => id !== 'none').map(([value, label]) => ({ value, label })),
|
|
244
184
|
{ group: 'Custom fields', options: groupable().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
245
185
|
];
|
|
246
|
-
const groupMenu = menu(
|
|
186
|
+
const groupMenu = menu(ICONS.group, (redraw) => {
|
|
247
187
|
const list = state.view.layout === 'list';
|
|
248
188
|
const group = state.view.group === 'none' && !list ? 'status' : state.view.group;
|
|
249
189
|
const none = group === 'none';
|
|
@@ -264,7 +204,7 @@ export async function renderJobs(main, ui) {
|
|
|
264
204
|
{ group: 'Custom fields', options: state.fields.map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
265
205
|
];
|
|
266
206
|
const setSorts = (sorts) => change({ sorts });
|
|
267
|
-
const sortMenu = menu(
|
|
207
|
+
const sortMenu = menu(ICONS.sort, (redraw) => {
|
|
268
208
|
const { sorts } = state.view;
|
|
269
209
|
const unused = sortOptions().flatMap((o) => o.options ?? [o]).filter((o) => !sorts.some((s) => s.by === o.value));
|
|
270
210
|
return [
|
|
@@ -329,105 +269,15 @@ export async function renderJobs(main, ui) {
|
|
|
329
269
|
default: return valueOf(job, fieldIdOf(field))?.value ?? null;
|
|
330
270
|
}
|
|
331
271
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (['before', 'after', 'on'].includes(op)) return typeof old === 'string' && /^\d{4}-/.test(old) ? old : today();
|
|
337
|
-
if (['eq', 'gt', 'lt'].includes(op)) return typeof old === 'number' ? old : null;
|
|
338
|
-
if (['has', 'is'].includes(op)) return typeof old === 'string' ? old : '';
|
|
339
|
-
return null;
|
|
340
|
-
}
|
|
341
|
-
const setFilters = (filters) => change({ filters });
|
|
342
|
-
const setRow = (i, patch) => setFilters(state.view.filters.map((f, n) => (n === i ? { ...f, ...patch } : f)));
|
|
343
|
-
// A choice row's value: a button that opens a list of choices to tick,
|
|
344
|
-
// with a search when the list is long.
|
|
345
|
-
function choiceValue(i, filter) {
|
|
346
|
-
const choices = choicesOf(filter.field);
|
|
347
|
-
const named = () => {
|
|
348
|
-
const on = choices.filter((c) => filter.value.includes(c.value)).map((c) => c.label);
|
|
349
|
-
return on.length ? on.join(', ') : 'Choose…';
|
|
350
|
-
};
|
|
351
|
-
const id = `fx-jobs-choice-${i}`;
|
|
352
|
-
const button = h('button', { type: 'button', class: 'fx-select fx-select--sm fx-rows__value', popovertarget: id, title: named() }, named());
|
|
353
|
-
const pop = h('div', { class: 'fx-pop fx-pop--choices', id, popover: 'auto', role: 'menu' });
|
|
354
|
-
const find = choices.length > 8 ? h('input', { class: 'fx-input fx-input--sm', type: 'search', placeholder: 'Find', 'aria-label': 'Find a choice' }) : null;
|
|
355
|
-
const items = h('div', {});
|
|
356
|
-
const drawItems = () => {
|
|
357
|
-
const q = find?.value.trim().toLowerCase() ?? '';
|
|
358
|
-
items.replaceChildren(...choices.filter((c) => !q || c.label.toLowerCase().includes(q)).map((c) => option(c.label, filter.value.includes(c.value), () => {
|
|
359
|
-
filter = { ...filter, value: filter.value.includes(c.value) ? filter.value.filter((v) => v !== c.value) : [...filter.value, c.value] };
|
|
360
|
-
setRow(i, { value: filter.value });
|
|
361
|
-
button.textContent = named();
|
|
362
|
-
button.title = named();
|
|
363
|
-
drawItems();
|
|
364
|
-
}, 'menuitemcheckbox')));
|
|
365
|
-
};
|
|
366
|
-
find?.addEventListener('input', drawItems);
|
|
367
|
-
pop.append(...[find, items].filter(Boolean));
|
|
368
|
-
pop.addEventListener('toggle', (event) => {
|
|
369
|
-
if (event.newState !== 'open') return;
|
|
370
|
-
drawItems();
|
|
371
|
-
const at = button.getBoundingClientRect();
|
|
372
|
-
pop.style.top = `${Math.round(at.bottom + 4)}px`;
|
|
373
|
-
pop.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - pop.offsetWidth - 8)))}px`;
|
|
374
|
-
find?.focus();
|
|
375
|
-
});
|
|
376
|
-
return [button, pop];
|
|
377
|
-
}
|
|
378
|
-
function valueControl(i, filter) {
|
|
379
|
-
const kind = kindOf(filter.field);
|
|
380
|
-
const { op } = filter;
|
|
381
|
-
if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return choiceValue(i, filter);
|
|
382
|
-
if (['before', 'after', 'on'].includes(op)) {
|
|
383
|
-
return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'date', 'aria-label': 'Date', value: filter.value ?? '', onchange: (event) => setRow(i, { value: event.target.value || null }) });
|
|
384
|
-
}
|
|
385
|
-
if (['eq', 'gt', 'lt'].includes(op)) {
|
|
386
|
-
return h('input', {
|
|
387
|
-
class: 'fx-input fx-input--sm fx-rows__value', type: 'number', step: 'any', inputmode: 'decimal', 'aria-label': 'Number', value: filter.value ?? '',
|
|
388
|
-
oninput: (event) => setRow(i, { value: event.target.value === '' ? null : Number(event.target.value) }),
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
if (['has', 'is'].includes(op)) {
|
|
392
|
-
return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'text', 'aria-label': 'Text', value: filter.value ?? '', oninput: (event) => setRow(i, { value: event.target.value }) });
|
|
393
|
-
}
|
|
394
|
-
return h('span', { class: 'fx-rows__value fx-rows__value--none' });
|
|
395
|
-
}
|
|
396
|
-
const filterMenu = menu(FILTER_ICON, (redraw) => {
|
|
397
|
-
const { filters, match } = state.view;
|
|
398
|
-
const fields = filterFields();
|
|
399
|
-
return [
|
|
400
|
-
title('Filters'),
|
|
401
|
-
filters.length ? h('div', { class: 'fx-rows' }, filters.map((filter, i) => {
|
|
402
|
-
const known = kindOf(filter.field);
|
|
403
|
-
const lead = i === 0 ? h('span', { class: 'fx-rows__lead' }, 'Where')
|
|
404
|
-
: i === 1 ? choose('Match', match, [{ value: 'all', label: 'And' }, { value: 'any', label: 'Or' }], (value) => { change({ match: value }); redraw(); })
|
|
405
|
-
: h('span', { class: 'fx-rows__lead' }, match === 'any' ? 'Or' : 'And');
|
|
406
|
-
if (!known) {
|
|
407
|
-
return h('div', { class: 'fx-rows__row' }, lead, h('span', { class: 'fx-rows__gone' }, 'A custom field that was deleted'), bin('Remove this filter', () => { setFilters(filters.filter((_, n) => n !== i)); redraw(); }));
|
|
408
|
-
}
|
|
409
|
-
const ops = opsOf(filter.field);
|
|
410
|
-
return h('div', { class: 'fx-rows__row' },
|
|
411
|
-
lead,
|
|
412
|
-
choose('Field', filter.field, fields, (field) => {
|
|
413
|
-
const op = Object.keys(opsOf(field))[0];
|
|
414
|
-
setRow(i, { field, op, value: valueFor(field, op, null) });
|
|
415
|
-
redraw();
|
|
416
|
-
}),
|
|
417
|
-
choose('Operator', filter.op, Object.entries(ops).map(([value, label]) => ({ value, label })), (op) => { setRow(i, { op, value: valueFor(filter.field, op, filter.value) }); redraw(); }),
|
|
418
|
-
valueControl(i, filter),
|
|
419
|
-
bin('Remove this filter', () => { setFilters(filters.filter((_, n) => n !== i)); redraw(); }));
|
|
420
|
-
})) : h('p', { class: 'fx-rows__empty' }, 'No filters. Every job shows.'),
|
|
421
|
-
rowsFoot(
|
|
422
|
-
filters.length < 20 ? addButton('Add filter', () => { setFilters([...filters, { field: 'status', op: 'is', value: [] }]); redraw(); }) : h('span'),
|
|
423
|
-
filters.length ? clearAll(() => { setFilters([]); redraw(); }) : null),
|
|
424
|
-
];
|
|
425
|
-
}, { rows: true, clearLabel: 'Clear all filters' });
|
|
272
|
+
const filterMenu = buildFilterMenu({
|
|
273
|
+
fields: filterFields, kindOf, opsOf, choicesOf, first: 'status',
|
|
274
|
+
view: () => state.view, change, empty: 'No filters. Every job shows.',
|
|
275
|
+
});
|
|
426
276
|
|
|
427
277
|
// Columns (the list only): Client always shows; each other column can be
|
|
428
278
|
// shown or hidden. Custom fields are columns too.
|
|
429
279
|
const heading = (text) => h('p', { class: 'fx-pop__head' }, text);
|
|
430
|
-
const columnsMenu = menu(
|
|
280
|
+
const columnsMenu = menu(ICONS.columns, (redraw) => {
|
|
431
281
|
const on = new Set(state.view.columns);
|
|
432
282
|
const flip = (id) => {
|
|
433
283
|
const next = on.has(id) ? state.view.columns.filter((c) => c !== id) : [...state.view.columns, id];
|
|
@@ -463,8 +313,7 @@ export async function renderJobs(main, ui) {
|
|
|
463
313
|
sortMenu.set(sorts.length > 1 ? `Sort · ${sorts.length}` : first ? `Sort: ${sortName(first.by)} ${first.dir === 'desc' ? '↓' : '↑'}` : 'Sort', sorts.length > 0,
|
|
464
314
|
sorts.length ? () => change({ sorts: [] }) : null);
|
|
465
315
|
sortMenu.wrap.hidden = list && !sorts.length;
|
|
466
|
-
|
|
467
|
-
filterMenu.set(count ? `${count} ${count === 1 ? 'Filter' : 'Filters'}` : 'Filter', count > 0, state.view.filters.length ? () => change({ filters: [], match: 'all' }) : null);
|
|
316
|
+
filterMenu.draw();
|
|
468
317
|
columnsMenu.set('Columns', false);
|
|
469
318
|
columnsMenu.wrap.hidden = !list;
|
|
470
319
|
}
|
|
@@ -898,7 +747,7 @@ export async function renderJobs(main, ui) {
|
|
|
898
747
|
const flip = () => change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)) });
|
|
899
748
|
columnPop.replaceChildren(
|
|
900
749
|
h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { close(); flip(); } },
|
|
901
|
-
h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${label}`), h('span', { class: 'fx-pop__end' }, svg(
|
|
750
|
+
h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${label}`), h('span', { class: 'fx-pop__end' }, svg(ICONS.tick))),
|
|
902
751
|
h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ sorts: sorts.filter((_, n) => n !== at) }); } },
|
|
903
752
|
h('span', { class: 'fx-pop__tick' }, lucide(h, 'ban')), h('span', {}, 'Clear sort')));
|
|
904
753
|
placeColumnPop(event);
|
|
@@ -1426,9 +1275,3 @@ export async function renderJobs(main, ui) {
|
|
|
1426
1275
|
|
|
1427
1276
|
await load();
|
|
1428
1277
|
}
|
|
1429
|
-
|
|
1430
|
-
function svg(markup) {
|
|
1431
|
-
const span = document.createElement('span');
|
|
1432
|
-
span.innerHTML = markup;
|
|
1433
|
-
return span.firstChild;
|
|
1434
|
-
}
|