@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.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +15 -5
- package/server/core/invoice-rules.js +49 -8
- package/server/core/job-match.js +2 -0
- package/server/core/sending.js +33 -5
- package/server/ui/flow.css +24 -38
- package/server/ui/invoice-rules.js +38 -13
- package/server/ui/invoicing.js +16 -54
- 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/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({
|
|
@@ -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,
|
|
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
|
-
}
|