@arcforgelabs/flow 2026.10.40 → 2026.10.42

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.
@@ -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({
@@ -238,7 +201,7 @@ export async function renderInvoiceList(main, ui) {
238
201
  });
239
202
  if (!ok) return;
240
203
  await post('send-ready', { issueDate: localDate(), invoiceIds: ticked },
241
- (res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, 'success']);
204
+ (res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.held ? ` ${res.held} failed a check and went to Pending.` : ''}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, res.held ? 'info' : 'success']);
242
205
  },
243
206
  });
244
207
  }
@@ -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
- }