@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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.40",
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.40",
9
+ "version": "2026.10.41",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.40",
3
+ "version": "2026.10.41",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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}
@@ -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 queue left, one job and its possible invoices right. */
709
- .fx-rec { display: grid; grid-template-columns: 280px minmax(0, 1fr); border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); background: var(--t-paper); min-height: 420px; }
710
- .fx-rec > .fx-empty, .fx-rec > .fx-dim { grid-column: 1 / -1; }
711
- .fx-rec__queue { display: flex; flex-direction: column; border-right: 1px solid var(--fx-line); max-height: 75vh; overflow-y: auto; }
712
- .fx-rec__tabs { display: flex; flex-wrap: wrap; gap: 6px; padding: 12px 16px; border-bottom: 1px solid var(--fx-line); }
713
- .fx-rec__tab { display: inline-flex; align-items: center; min-height: 32px; padding: 0 10px; border: 1px solid var(--fx-line); border-radius: 999px; color: var(--t-text); font-size: 12.5px; font-weight: 600; text-decoration: none; }
714
- .fx-rec__tab[aria-selected="true"] { border-color: var(--t-primary); background: var(--t-primary); color: var(--t-on-primary); }
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-rec__item { display: grid; gap: 3px; min-height: 44px; padding: 12px 16px; border-bottom: 1px solid var(--fx-line-soft); border-left: 3px solid transparent; color: var(--t-text); text-decoration: none; }
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
- @media (max-width: 1100px) {
751
- .fx-rec { grid-template-columns: minmax(0, 1fr); min-height: 0; }
752
- .fx-rec__queue { border-right: 0; max-height: none; }
753
- .fx-rec--open .fx-rec__queue { display: none; }
754
- .fx-rec:not(.fx-rec--open) .fx-rec__detail { display: none; }
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-rec__foot .fx-btn { min-height: 44px; }
762
- .fx-rec__foot-acts { width: 100%; }
763
- .fx-rec__foot-acts .fx-btn { flex: 1 1 auto; }
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; }
@@ -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
- const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice', value: state.q });
122
- const bar = h('div', { class: 'fx-sendbar', hidden: true });
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, search and send bar stay at the top while the rows scroll up
130
- // under them; the bulk bar floats at the foot (ClickUp's layout).
131
- const head = h('div', { class: 'fx-listhead' }, h('div', { class: 'fx-toolbar' }, tabs, search), bar);
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
- const action = (label, path, payload, done, tone = 'primary') => {
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 ? 'Send' : 'Approve', primary: true,
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, filterCount, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
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 search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a case, client or practitioner', 'aria-label': 'Find a job', value: asked });
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' }, search, toggle)),
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
- // One toolbar control: a chip that opens a small panel under it, and an ×
181
- // that clears what the chip sets (as ClickUp's "Clear all filters"). The
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(GROUP_ICON, (redraw) => {
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(SORT_ICON, (redraw) => {
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
- // A row's value when its operator changes: kept when it still fits.
333
- function valueFor(field, op, old) {
334
- const kind = kindOf(field);
335
- if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return Array.isArray(old) ? old : [];
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(COLUMNS_ICON, (redraw) => {
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
- const count = filterCount(state.view);
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(TICK_ICON))),
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
- }