@arcforgelabs/flow 2026.10.38 → 2026.10.39
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/README.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +26 -9
- package/server/api/invoicing.js +149 -51
- package/server/app.js +5 -2
- package/server/core/alerts.js +1 -1
- package/server/core/automations.js +1 -1
- package/server/core/custom-fields.js +74 -6
- package/server/core/ledger.js +29 -7
- package/server/core/price-list.js +27 -2
- package/server/core/read-before-act.js +3 -2
- package/server/core/sending.js +36 -18
- package/server/db/doctor.js +18 -19
- package/server/db/index.js +74 -3
- package/server/db/migrate-all.js +3 -4
- package/server/db/migrations/0020_custom_field_colours.sql +21 -0
- package/server/db/migrations/meta/0020_snapshot.json +4377 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +14 -0
- package/server/integrations/call-guard.js +148 -0
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/index.js +5 -1
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +25 -1
- package/server/integrations/xero/routes.js +24 -7
- package/server/ui/app.js +35 -3
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +185 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +168 -33
- package/server/ui/jobs.js +316 -65
- package/server/ui/price-list.js +43 -86
- package/server/ui/price-match.js +93 -0
- package/server/ui/price-reconcile.js +357 -0
- package/server/ui/reconcile.js +1 -1
- package/server/ui/routes.js +1 -1
package/server/ui/jobs.js
CHANGED
|
@@ -19,7 +19,8 @@ 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, filterCount, filterJobs, groupJobs, GROUPS,
|
|
22
|
+
COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterCount, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
|
|
23
|
+
normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
|
|
23
24
|
} from '/assets/ui/job-view.js';
|
|
24
25
|
|
|
25
26
|
const LAYOUT_KEY = 'flow_jobs_layout';
|
|
@@ -144,13 +145,9 @@ export async function renderJobs(main, ui) {
|
|
|
144
145
|
const valueOf = (job, fieldId) => state.values.get(valueKey(job, fieldId)) ?? null;
|
|
145
146
|
const groupable = () => state.fields.filter((f) => f.groupable);
|
|
146
147
|
// The Assignee: the lab's first person custom field (Flow makes one,
|
|
147
|
-
// server/core/custom-fields.js ensureAssignee). "Assignee
|
|
148
|
-
// jobs it names the person looking at.
|
|
148
|
+
// server/core/custom-fields.js ensureAssignee). The filter "Assignee is
|
|
149
|
+
// Me" keeps the jobs it names the person looking at.
|
|
149
150
|
const assigneeField = () => state.fields.find((f) => f.type === 'person') ?? null;
|
|
150
|
-
const isMine = (job) => {
|
|
151
|
-
const field = assigneeField();
|
|
152
|
-
return Boolean(field && ui.me?.person?.id && valueOf(job, field.id)?.value === ui.me.person.id);
|
|
153
|
-
};
|
|
154
151
|
// The list's columns a person can choose: Patient only when the
|
|
155
152
|
// installation keeps patient names; Assignee only with a person field.
|
|
156
153
|
const builtInColumns = () => Object.keys(COLUMNS).filter((id) => (id !== 'patient' || state.jobs.some((job) => job.patient))
|
|
@@ -180,13 +177,18 @@ export async function renderJobs(main, ui) {
|
|
|
180
177
|
}, 500);
|
|
181
178
|
}
|
|
182
179
|
|
|
183
|
-
// One toolbar control: a
|
|
184
|
-
//
|
|
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.
|
|
185
183
|
let menuCount = 0;
|
|
186
|
-
function menu(icon, build) {
|
|
184
|
+
function menu(icon, build, { rows = false, clearLabel = 'Clear' } = {}) {
|
|
187
185
|
const id = `fx-jobs-menu-${++menuCount}`;
|
|
188
|
-
const panel = h('div', { class: 'fx-pop'
|
|
189
|
-
const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' });
|
|
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?.());
|
|
190
192
|
const fill = () => panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
|
|
191
193
|
panel.addEventListener('toggle', (event) => {
|
|
192
194
|
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
@@ -197,10 +199,15 @@ export async function renderJobs(main, ui) {
|
|
|
197
199
|
panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
198
200
|
});
|
|
199
201
|
return {
|
|
200
|
-
node: [
|
|
201
|
-
|
|
202
|
+
node: [wrap, panel],
|
|
203
|
+
wrap,
|
|
204
|
+
fill: () => { if (panel.matches(':popover-open')) fill(); },
|
|
205
|
+
set(label, on, clearIt = null) {
|
|
202
206
|
button.replaceChildren(svg(icon), h('span', {}, label));
|
|
207
|
+
wrap.classList.toggle('fx-chipset--on', on);
|
|
203
208
|
button.classList.toggle('fx-chip--on', on);
|
|
209
|
+
onClear = clearIt;
|
|
210
|
+
clear.hidden = !clearIt;
|
|
204
211
|
},
|
|
205
212
|
};
|
|
206
213
|
}
|
|
@@ -209,49 +216,217 @@ export async function renderJobs(main, ui) {
|
|
|
209
216
|
const option = (text, checked, onclick, role = 'menuitemradio') => h('button', {
|
|
210
217
|
type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
|
|
211
218
|
}, h('span', { class: 'fx-pop__tick' }, checked ? svg(TICK_ICON) : null), h('span', {}, text));
|
|
212
|
-
const heading = (text) => h('p', { class: 'fx-pop__head' }, text);
|
|
213
219
|
const DATE_WORDS = ['Oldest first', 'Newest first'];
|
|
214
|
-
const
|
|
220
|
+
const NUMBER_WORDS = ['Low to high', 'High to low'];
|
|
221
|
+
const DIR_WORDS = { total: NUMBER_WORDS, due: DATE_WORDS, ordered: DATE_WORDS, shipped: DATE_WORDS };
|
|
222
|
+
const TYPE_WORDS = { date: DATE_WORDS, number: NUMBER_WORDS };
|
|
223
|
+
const dirWords = (by) => DIR_WORDS[by] ?? TYPE_WORDS[fieldById(fieldIdOf(by))?.type] ?? ['A to Z', 'Z to A'];
|
|
224
|
+
const fieldById = (id) => state.fields.find((f) => f.id === id) ?? null;
|
|
225
|
+
|
|
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
|
+
// ── Group by ──
|
|
240
|
+
// One row: what to group by, the group order, and a bin that takes the
|
|
241
|
+
// groups away (the list only: the board always has lanes).
|
|
242
|
+
const groupOptions = () => [
|
|
243
|
+
...Object.entries(GROUPS).filter(([id]) => id !== 'none').map(([value, label]) => ({ value, label })),
|
|
244
|
+
{ group: 'Custom fields', options: groupable().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
245
|
+
];
|
|
246
|
+
const groupMenu = menu(GROUP_ICON, (redraw) => {
|
|
247
|
+
const list = state.view.layout === 'list';
|
|
248
|
+
const group = state.view.group === 'none' && !list ? 'status' : state.view.group;
|
|
249
|
+
const none = group === 'none';
|
|
250
|
+
return [
|
|
251
|
+
title('Group by'),
|
|
252
|
+
h('div', { class: 'fx-rows' }, h('div', { class: 'fx-rows__row fx-rows__row--group' },
|
|
253
|
+
choose('Group by', none ? '' : group, [...(none ? [{ value: '', label: 'Choose…', disabled: true }] : []), ...groupOptions()], (value) => { change({ group: value }); redraw(); }),
|
|
254
|
+
choose('Group order', state.view.groupDir, [{ value: 'asc', label: 'Ascending' }, { value: 'desc', label: 'Descending' }], (value) => { change({ groupDir: value }); redraw(); }, { disabled: none }),
|
|
255
|
+
list && !none ? bin('Remove the grouping', () => { change({ group: 'none' }); redraw(); }) : h('span', { class: 'fx-rows__gap' }))),
|
|
256
|
+
];
|
|
257
|
+
}, { rows: true, clearLabel: 'Remove the grouping' });
|
|
215
258
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
]
|
|
259
|
+
// ── Sort ──
|
|
260
|
+
// Rows of sorts, each applied in turn. In the list, a column's head sorts
|
|
261
|
+
// too.
|
|
262
|
+
const sortOptions = () => [
|
|
263
|
+
...Object.entries(SORTS).map(([value, label]) => ({ value, label })),
|
|
264
|
+
{ group: 'Custom fields', options: state.fields.map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
265
|
+
];
|
|
266
|
+
const setSorts = (sorts) => change({ sorts });
|
|
223
267
|
const sortMenu = menu(SORT_ICON, (redraw) => {
|
|
224
|
-
const
|
|
268
|
+
const { sorts } = state.view;
|
|
269
|
+
const unused = sortOptions().flatMap((o) => o.options ?? [o]).filter((o) => !sorts.some((s) => s.by === o.value));
|
|
225
270
|
return [
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
271
|
+
title('Sort'),
|
|
272
|
+
sorts.length ? h('div', { class: 'fx-rows' }, sorts.map((s, i) => {
|
|
273
|
+
const [up, down] = dirWords(s.by);
|
|
274
|
+
const others = sorts.filter((_, n) => n !== i).map((x) => x.by);
|
|
275
|
+
const options = sortOptions().map((o) => (o.group ? { ...o, options: o.options.filter((x) => !others.includes(x.value)) } : o)).filter((o) => o.group || !others.includes(o.value));
|
|
276
|
+
return h('div', { class: 'fx-rows__row fx-rows__row--sort' },
|
|
277
|
+
h('span', { class: 'fx-rows__lead' }, i ? 'then' : 'Sort by'),
|
|
278
|
+
choose('Sort by', s.by, options, (by) => { setSorts(sorts.map((x, n) => (n === i ? { by, dir: x.dir } : x))); redraw(); }),
|
|
279
|
+
choose('Order', s.dir, [{ value: 'asc', label: up }, { value: 'desc', label: down }], (dir) => { setSorts(sorts.map((x, n) => (n === i ? { ...x, dir } : x))); redraw(); }),
|
|
280
|
+
bin('Remove this sort', () => { setSorts(sorts.filter((_, n) => n !== i)); redraw(); }));
|
|
281
|
+
})) : h('p', { class: 'fx-rows__empty' }, 'No sort. Jobs show in the lab system’s order.'),
|
|
282
|
+
rowsFoot(
|
|
283
|
+
sorts.length < 5 && unused.length ? addButton('Add sort', () => { setSorts([...sorts, { by: unused[0].value, dir: 'asc' }]); redraw(); }) : h('span'),
|
|
284
|
+
sorts.length ? clearAll(() => { setSorts([]); redraw(); }) : null),
|
|
231
285
|
];
|
|
232
|
-
});
|
|
286
|
+
}, { rows: true, clearLabel: 'Clear the sort' });
|
|
287
|
+
|
|
288
|
+
// ── Filters ──
|
|
289
|
+
// Rows of field, operator and value, with a bin on each, "Add filter",
|
|
290
|
+
// and "Clear all". The second row's And / Or applies to every row.
|
|
291
|
+
const filterFields = () => [
|
|
292
|
+
...Object.entries(FILTER_FIELDS).filter(([id]) => id !== 'assignee' || assigneeField())
|
|
293
|
+
.map(([value, f]) => ({ value, label: value === 'assignee' ? assigneeField().name : f.label })),
|
|
294
|
+
{ group: 'Custom fields', options: customColumns().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
295
|
+
];
|
|
296
|
+
const kindOf = (field) => {
|
|
297
|
+
if (field === 'assignee') return assigneeField() ? 'person' : null;
|
|
298
|
+
if (Object.hasOwn(FILTER_FIELDS, field)) return FILTER_FIELDS[field].kind;
|
|
299
|
+
return KIND_OF_TYPE[fieldById(fieldIdOf(field))?.type] ?? null;
|
|
300
|
+
};
|
|
301
|
+
const PROBLEM_OPS = { yes: 'has one', no: 'has none' };
|
|
302
|
+
const opsOf = (field) => (field === 'problem' ? PROBLEM_OPS : OPERATORS[kindOf(field)] ?? {});
|
|
303
|
+
// The choices a choice or person filter offers: [{ value, label }].
|
|
304
|
+
function choicesOf(field) {
|
|
305
|
+
const people = () => [{ value: 'me', label: 'Me' }, ...state.people.map((p) => ({ value: p.id, label: p.name }))];
|
|
306
|
+
const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
|
|
307
|
+
if (field === 'status') return orderStatuses(state.statuses, state.jobs).map((v) => ({ value: v, label: v }));
|
|
308
|
+
if (field === 'stage') return Object.values(STAGE_GROUPS).map((v) => ({ value: v, label: v }));
|
|
309
|
+
if (field === 'client') return labels(state.jobs.map(clientOf));
|
|
310
|
+
if (field === 'practitioner') return labels(state.jobs.map((job) => job.practitioner));
|
|
311
|
+
if (field === 'assignee') return people();
|
|
312
|
+
const custom = fieldById(fieldIdOf(field));
|
|
313
|
+
if (custom?.type === 'person') return people();
|
|
314
|
+
return (custom?.options ?? []).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name }));
|
|
315
|
+
}
|
|
316
|
+
// A job's value for a filter field, as the filter compares it.
|
|
317
|
+
function filterValue(job, field) {
|
|
318
|
+
switch (field) {
|
|
319
|
+
case 'status': return statusOf(job);
|
|
320
|
+
case 'stage': return STAGE_GROUPS[invoiceStage(job.obligation) ?? 'in_work'];
|
|
321
|
+
case 'client': return clientOf(job);
|
|
322
|
+
case 'practitioner': return job.practitioner ?? null;
|
|
323
|
+
case 'assignee': return assigneeField() ? valueOf(job, assigneeField().id)?.value ?? null : null;
|
|
324
|
+
case 'due': return job.dueOn ?? null;
|
|
325
|
+
case 'ordered': return job.orderedOn ?? null;
|
|
326
|
+
case 'shipped': return job.shippedOn ?? null;
|
|
327
|
+
case 'total': return job.totalCents == null ? null : job.totalCents / 100;
|
|
328
|
+
case 'problem': return problemsOf(job).length > 0;
|
|
329
|
+
default: return valueOf(job, fieldIdOf(field))?.value ?? null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
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
|
+
}
|
|
233
396
|
const filterMenu = menu(FILTER_ICON, (redraw) => {
|
|
234
|
-
const
|
|
397
|
+
const { filters, match } = state.view;
|
|
398
|
+
const fields = filterFields();
|
|
235
399
|
return [
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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),
|
|
251
424
|
];
|
|
252
|
-
});
|
|
425
|
+
}, { rows: true, clearLabel: 'Clear all filters' });
|
|
426
|
+
|
|
253
427
|
// Columns (the list only): Client always shows; each other column can be
|
|
254
428
|
// shown or hidden. Custom fields are columns too.
|
|
429
|
+
const heading = (text) => h('p', { class: 'fx-pop__head' }, text);
|
|
255
430
|
const columnsMenu = menu(COLUMNS_ICON, (redraw) => {
|
|
256
431
|
const on = new Set(state.view.columns);
|
|
257
432
|
const flip = (id) => {
|
|
@@ -271,18 +446,27 @@ export async function renderJobs(main, ui) {
|
|
|
271
446
|
});
|
|
272
447
|
controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node, ...columnsMenu.node);
|
|
273
448
|
|
|
449
|
+
// A sort's name: a built-in key or a custom field.
|
|
450
|
+
const sortName = (by) => SORTS[by] ?? fieldById(fieldIdOf(by))?.name ?? 'Custom field';
|
|
274
451
|
function drawControls() {
|
|
275
|
-
const { group,
|
|
452
|
+
const { group, sorts, layout } = state.view;
|
|
453
|
+
const list = layout === 'list';
|
|
276
454
|
const field = fieldOfGroup(group);
|
|
277
455
|
// The board has no "None": it shows the status lanes.
|
|
278
|
-
const shown = group === 'none' &&
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
456
|
+
const shown = group === 'none' && !list ? 'status' : group;
|
|
457
|
+
const label = shown === 'none' ? 'Group: None' : `Group: ${field?.name ?? GROUPS[shown] ?? GROUPS.status}`;
|
|
458
|
+
const grouped = list ? shown !== 'none' : shown !== 'status';
|
|
459
|
+
groupMenu.set(label, shown !== DEFAULT_VIEW.group, grouped ? () => change({ group: list ? 'none' : 'status' }) : null);
|
|
460
|
+
// In the list, the column heads sort; the Sort chip shows there only
|
|
461
|
+
// while a sort is on, so it can be seen and cleared.
|
|
462
|
+
const [first] = sorts;
|
|
463
|
+
sortMenu.set(sorts.length > 1 ? `Sort · ${sorts.length}` : first ? `Sort: ${sortName(first.by)} ${first.dir === 'desc' ? '↓' : '↑'}` : 'Sort', sorts.length > 0,
|
|
464
|
+
sorts.length ? () => change({ sorts: [] }) : null);
|
|
465
|
+
sortMenu.wrap.hidden = list && !sorts.length;
|
|
282
466
|
const count = filterCount(state.view);
|
|
283
|
-
filterMenu.set(count ?
|
|
467
|
+
filterMenu.set(count ? `${count} ${count === 1 ? 'Filter' : 'Filters'}` : 'Filter', count > 0, state.view.filters.length ? () => change({ filters: [], match: 'all' }) : null);
|
|
284
468
|
columnsMenu.set('Columns', false);
|
|
285
|
-
columnsMenu.
|
|
469
|
+
columnsMenu.wrap.hidden = !list;
|
|
286
470
|
}
|
|
287
471
|
drawControls();
|
|
288
472
|
|
|
@@ -295,8 +479,8 @@ export async function renderJobs(main, ui) {
|
|
|
295
479
|
return;
|
|
296
480
|
}
|
|
297
481
|
const found = filterJobs(state.jobs.filter((job) => matches(job, state.q)), state.view,
|
|
298
|
-
{
|
|
299
|
-
const groups = grouped(sortJobs(found, state.view));
|
|
482
|
+
{ valueOf: filterValue, kindOf, late: isLate, me: ui.me?.person?.id ?? null, today: today() });
|
|
483
|
+
const groups = grouped(sortJobs(found, state.view, sortKey));
|
|
300
484
|
main.classList.toggle('fx-main--board', state.view.layout === 'board');
|
|
301
485
|
// A tick on a job the search or filter now hides goes.
|
|
302
486
|
const shown = new Set(found.map(jobKey));
|
|
@@ -379,7 +563,12 @@ export async function renderJobs(main, ui) {
|
|
|
379
563
|
// A group's `drop` says what a card dropped in it changes: a custom
|
|
380
564
|
// field's value, or the job's stage in the work system. No `drop`: cards
|
|
381
565
|
// cannot be dropped there (invoice stage, client, practitioner).
|
|
566
|
+
// "Descending" shows the groups in the other order.
|
|
382
567
|
function grouped(jobs) {
|
|
568
|
+
const groups = groupsOf(jobs);
|
|
569
|
+
return state.view.groupDir === 'desc' ? groups.reverse() : groups;
|
|
570
|
+
}
|
|
571
|
+
function groupsOf(jobs) {
|
|
383
572
|
const { group } = state.view;
|
|
384
573
|
const field = fieldOfGroup(group);
|
|
385
574
|
if (field) {
|
|
@@ -393,8 +582,7 @@ export async function renderJobs(main, ui) {
|
|
|
393
582
|
}
|
|
394
583
|
if (group === 'none' && state.view.layout === 'list') return [{ label: null, jobs, rank: 'plain' }];
|
|
395
584
|
if (group === 'status' || group === 'none' || !GROUPS[group]) {
|
|
396
|
-
|
|
397
|
-
return groupJobs(jobs, orderStatuses(state.statuses, state.jobs).filter((label) => !hidden.has(label)), laneStatusOf)
|
|
585
|
+
return groupJobs(jobs, keptStatuses(state.view, orderStatuses(state.statuses, state.jobs)), laneStatusOf)
|
|
398
586
|
.map((g) => ({ ...g, rank: statusRank(g.label), drop: g.label === NO_STATUS ? null : { kind: 'stage', status: g.label } }));
|
|
399
587
|
}
|
|
400
588
|
if (group === 'stage') {
|
|
@@ -656,7 +844,65 @@ export async function renderJobs(main, ui) {
|
|
|
656
844
|
// A column's menu (a click on its head, or a right-click): sort by it,
|
|
657
845
|
// move it, hide it. Client always shows. `column` null is the + menu: the
|
|
658
846
|
// hidden columns, to add one back.
|
|
659
|
-
|
|
847
|
+
// What a column's head sorts by: a built-in key, or the column's custom
|
|
848
|
+
// field (Assignee is the lab's person field).
|
|
849
|
+
const SORT_OF = { client: 'client', practitioner: 'practitioner', due: 'due', ordered: 'ordered', shipped: 'shipped', total: 'total' };
|
|
850
|
+
const sortOf = (columnId) => SORT_OF[columnId]
|
|
851
|
+
?? (columnId === 'assignee' && assigneeField() ? fieldGroup(assigneeField().id) : fieldIdOf(columnId) ? columnId : null);
|
|
852
|
+
// A custom field's sort key: a person's name, an option's place in the
|
|
853
|
+
// list, ticked first or last, or the value.
|
|
854
|
+
function sortKey(job, by) {
|
|
855
|
+
const field = fieldById(fieldIdOf(by));
|
|
856
|
+
const value = field ? valueOf(job, field.id)?.value ?? null : null;
|
|
857
|
+
if (!field || value == null) return null;
|
|
858
|
+
if (field.type === 'person') return (state.people.find((p) => p.id === value)?.name ?? '').toLowerCase() || null;
|
|
859
|
+
if (field.type === 'dropdown') {
|
|
860
|
+
const at = (field.options ?? []).findIndex((o) => o.id === value);
|
|
861
|
+
return at < 0 ? null : at;
|
|
862
|
+
}
|
|
863
|
+
if (field.type === 'checkbox') return value === true ? 1 : 0;
|
|
864
|
+
return typeof value === 'string' ? value.toLowerCase() : value;
|
|
865
|
+
}
|
|
866
|
+
// A column head's sort button, as ClickUp's: it shows on hover, or
|
|
867
|
+
// always while it sorts. A click sorts one way, again the other way, and
|
|
868
|
+
// a third time takes the sort off. A right-click on a sorting head opens
|
|
869
|
+
// a small menu: change the direction, or clear the sort.
|
|
870
|
+
function sortButton(columnId, label) {
|
|
871
|
+
const by = sortOf(columnId);
|
|
872
|
+
if (!by) return null;
|
|
873
|
+
const { sorts } = state.view;
|
|
874
|
+
const at = sorts.findIndex((s) => s.by === by);
|
|
875
|
+
const on = at >= 0 ? sorts[at] : null;
|
|
876
|
+
const [up, down] = dirWords(by);
|
|
877
|
+
const next = () => {
|
|
878
|
+
if (!on) change({ sorts: [{ by, dir: 'asc' }] });
|
|
879
|
+
else if (on.dir === 'asc') change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: 'desc' } : s)) });
|
|
880
|
+
else change({ sorts: sorts.filter((_, n) => n !== at) });
|
|
881
|
+
};
|
|
882
|
+
const words = on ? `Sorted ${on.dir === 'asc' ? up : down}. Click to ${on.dir === 'asc' ? `sort ${down}` : 'clear the sort'}` : `Sort by ${label}`;
|
|
883
|
+
return h('button', {
|
|
884
|
+
type: 'button', class: `fx-th-sort${on ? ' fx-th-sort--on' : ''}`, 'aria-label': words, title: words,
|
|
885
|
+
onclick: (event) => { event.stopPropagation(); next(); },
|
|
886
|
+
oncontextmenu: (event) => {
|
|
887
|
+
if (!on) return;
|
|
888
|
+
event.preventDefault();
|
|
889
|
+
event.stopPropagation();
|
|
890
|
+
onRightButton(event, () => sortMenuAt(event, by, label, on, at));
|
|
891
|
+
},
|
|
892
|
+
}, lucide(h, on ? (on.dir === 'asc' ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'),
|
|
893
|
+
on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
|
|
894
|
+
}
|
|
895
|
+
function sortMenuAt(event, by, label, on, at) {
|
|
896
|
+
const close = () => columnPop.hidePopover?.();
|
|
897
|
+
const { sorts } = state.view;
|
|
898
|
+
const flip = () => change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)) });
|
|
899
|
+
columnPop.replaceChildren(
|
|
900
|
+
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))),
|
|
902
|
+
h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ sorts: sorts.filter((_, n) => n !== at) }); } },
|
|
903
|
+
h('span', { class: 'fx-pop__tick' }, lucide(h, 'ban')), h('span', {}, 'Clear sort')));
|
|
904
|
+
placeColumnPop(event);
|
|
905
|
+
}
|
|
660
906
|
const columnPop = h('div', { class: 'fx-pop', id: 'fx-jobs-colmenu', popover: 'auto', role: 'menu' });
|
|
661
907
|
document.body.append(columnPop);
|
|
662
908
|
// A popover opened while the right button is still down closes again on
|
|
@@ -672,11 +918,11 @@ export async function renderJobs(main, ui) {
|
|
|
672
918
|
h('span', { class: 'fx-pop__tick' }, lucide(h, icon)), h('span', {}, text));
|
|
673
919
|
const parts = [];
|
|
674
920
|
if (column) {
|
|
675
|
-
const sort =
|
|
921
|
+
const sort = sortOf(column.id);
|
|
676
922
|
if (sort) {
|
|
677
|
-
const [up, down] =
|
|
678
|
-
parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sort, dir: 'asc' })),
|
|
679
|
-
item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sort, dir: 'desc' })), h('div', { class: 'fx-pop__sep' }));
|
|
923
|
+
const [up, down] = dirWords(sort);
|
|
924
|
+
parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sorts: [{ by: sort, dir: 'asc' }] })),
|
|
925
|
+
item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sorts: [{ by: sort, dir: 'desc' }] })), h('div', { class: 'fx-pop__sep' }));
|
|
680
926
|
}
|
|
681
927
|
const rest = ids.filter((c) => c !== column.id);
|
|
682
928
|
parts.push(item('arrow-left-to-line', 'Move to start', () => change({ columns: [column.id, ...rest] })),
|
|
@@ -697,6 +943,9 @@ export async function renderJobs(main, ui) {
|
|
|
697
943
|
if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
|
|
698
944
|
}
|
|
699
945
|
columnPop.replaceChildren(...parts);
|
|
946
|
+
placeColumnPop(event);
|
|
947
|
+
}
|
|
948
|
+
function placeColumnPop(event) {
|
|
700
949
|
columnPop.showPopover?.();
|
|
701
950
|
const x = event.clientX || event.currentTarget?.getBoundingClientRect?.().left || 0;
|
|
702
951
|
const y = event.clientY || event.currentTarget?.getBoundingClientRect?.().bottom || 0;
|
|
@@ -809,9 +1058,11 @@ export async function renderJobs(main, ui) {
|
|
|
809
1058
|
return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
|
|
810
1059
|
h('thead', {}, h('tr', {},
|
|
811
1060
|
h('th', { class: 'fx-tick-cell' }, setBox(allJobs, 'Tick every job shown')),
|
|
812
|
-
h('th', {}, 'Client'),
|
|
1061
|
+
h('th', {}, h('span', { class: 'fx-th-cell' }, h('span', { class: 'fx-th fx-th--fixed' }, 'Client'), sortButton('client', 'Client'))),
|
|
813
1062
|
columns.map((c) => h('th', { class: c.num ? 'fx-num' : null, oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => columnMenu(c, event)); } },
|
|
814
|
-
h('
|
|
1063
|
+
h('span', { class: 'fx-th-cell' },
|
|
1064
|
+
h('button', { type: 'button', class: 'fx-th', 'aria-haspopup': 'menu', title: 'Column menu (or right-click)', onclick: (event) => columnMenu(c, event) }, c.label),
|
|
1065
|
+
sortButton(c.id, c.label)))),
|
|
815
1066
|
h('th', { class: 'fx-th-add' }, h('button', {
|
|
816
1067
|
type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => columnMenu(null, event),
|
|
817
1068
|
}, '+')))),
|