@arcforgelabs/flow 2026.10.37 → 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 +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +27 -9
- package/server/api/invoicing.js +198 -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 +89 -3
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +109 -8
- 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/feed.js +4 -1
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +6 -2
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +42 -12
- package/server/integrations/xero/routes.js +66 -7
- package/server/integrations/xero/settings.js +6 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +89 -3
- package/server/ui/bulk-bar.js +4 -3
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +326 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +9 -7
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +189 -31
- package/server/ui/jobs.js +738 -70
- 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 +275 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-import.js +329 -0
package/server/ui/jobs.js
CHANGED
|
@@ -12,20 +12,24 @@
|
|
|
12
12
|
// shows what to do there and the card waits in its new lane. On a phone,
|
|
13
13
|
// or with a keyboard, the job's panel has the same moves.
|
|
14
14
|
|
|
15
|
+
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
16
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
15
17
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
16
18
|
import {
|
|
17
19
|
caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
|
|
18
20
|
} from '/assets/ui/job-board.js';
|
|
19
21
|
import {
|
|
20
|
-
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,
|
|
21
24
|
} from '/assets/ui/job-view.js';
|
|
22
25
|
|
|
23
26
|
const LAYOUT_KEY = 'flow_jobs_layout';
|
|
27
|
+
const FOLDED_KEY = 'flow_jobs_folded';
|
|
24
28
|
const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
|
|
25
29
|
// A job's events (server/core/ledger.js), in plain words.
|
|
26
30
|
const ACTIVITY = {
|
|
27
31
|
observed: 'Flow read the job', source_changed: 'The job changed in the lab system', ready: 'Marked ready to invoice',
|
|
28
|
-
in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused',
|
|
32
|
+
in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused', invoice_linked: 'Linked to an invoice',
|
|
29
33
|
accounting_checked: 'Checked in the accounting system', payment_recorded: 'Payment recorded', paid: 'Paid in full',
|
|
30
34
|
held: 'Put on hold', review: 'Sent to review', released: 'Hold removed',
|
|
31
35
|
status_moved_back: 'The job status moved back in the lab system', source_lines_used: 'New job lines used on the draft',
|
|
@@ -39,6 +43,23 @@ const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4
|
|
|
39
43
|
|
|
40
44
|
const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
|
|
41
45
|
const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
|
|
46
|
+
// A list date, short: Today, Tomorrow or Yesterday, else day/month (and
|
|
47
|
+
// year when it is not this year).
|
|
48
|
+
const shortDate = (iso) => {
|
|
49
|
+
if (!iso) return '—';
|
|
50
|
+
const at = new Date(`${iso}T00:00:00`);
|
|
51
|
+
const now = new Date();
|
|
52
|
+
const days = Math.round((at - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
|
|
53
|
+
if (days === 0) return 'Today';
|
|
54
|
+
if (days === 1) return 'Tomorrow';
|
|
55
|
+
if (days === -1) return 'Yesterday';
|
|
56
|
+
return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
|
|
57
|
+
};
|
|
58
|
+
// A person's badge: their initials in a circle; the name on hover.
|
|
59
|
+
const initialsOf = (name) => {
|
|
60
|
+
const parts = (name ?? '?').split(/[\s._-]+/).filter(Boolean);
|
|
61
|
+
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
|
|
62
|
+
};
|
|
42
63
|
const clientOf = (job) => job.clientName ?? 'No client';
|
|
43
64
|
// The one date a card shows: when the job is due, else when it was ordered.
|
|
44
65
|
const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
|
|
@@ -51,6 +72,18 @@ function saveLayout(layout) {
|
|
|
51
72
|
try { localStorage.setItem(LAYOUT_KEY, layout); } catch { /* the choice is only a convenience */ }
|
|
52
73
|
}
|
|
53
74
|
|
|
75
|
+
// The list's folded groups, by group key, in this browser only.
|
|
76
|
+
function readFolded() {
|
|
77
|
+
try {
|
|
78
|
+
const value = JSON.parse(localStorage.getItem(FOLDED_KEY) ?? '{}');
|
|
79
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
80
|
+
} catch { return {}; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function saveFolded(folded) {
|
|
84
|
+
try { localStorage.setItem(FOLDED_KEY, JSON.stringify(folded)); } catch { /* a convenience only */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
54
87
|
// The local calendar day, as yyyy-mm-dd, to mark late jobs.
|
|
55
88
|
const today = () => {
|
|
56
89
|
const now = new Date();
|
|
@@ -61,8 +94,10 @@ const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="
|
|
|
61
94
|
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>';
|
|
62
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>';
|
|
63
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>';
|
|
64
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>';
|
|
65
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
|
+
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>';
|
|
66
101
|
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>';
|
|
67
102
|
|
|
68
103
|
export async function renderJobs(main, ui) {
|
|
@@ -81,16 +116,21 @@ export async function renderJobs(main, ui) {
|
|
|
81
116
|
fields: [], systemFields: [], people: [], values: new Map(),
|
|
82
117
|
// Each work system's "Ask before stage moves", read on the first move.
|
|
83
118
|
askBefore: new Map(),
|
|
119
|
+
// The list's ticked jobs (by jobKey), and its folded groups by group key.
|
|
120
|
+
ticked: new Set(), folded: readFolded(),
|
|
84
121
|
};
|
|
85
122
|
const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
|
|
86
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 });
|
|
87
124
|
const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
|
|
88
125
|
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
|
|
126
|
+
// In the list, ticked rows get the same floating bar as Invoices.
|
|
127
|
+
const bulk = bulkBar(ui, { label: 'Ticked jobs' });
|
|
89
128
|
main.classList.add('fx-main--wide');
|
|
90
129
|
main.append(
|
|
91
130
|
pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
|
|
92
131
|
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, search, toggle)),
|
|
93
|
-
host
|
|
132
|
+
host,
|
|
133
|
+
bulk.element);
|
|
94
134
|
|
|
95
135
|
const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
|
|
96
136
|
type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
|
|
@@ -100,9 +140,19 @@ export async function renderJobs(main, ui) {
|
|
|
100
140
|
search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
|
|
101
141
|
|
|
102
142
|
const problemsOf = (job) => fixes(job.obligation);
|
|
143
|
+
const jobKey = (job) => `${job.sourceSystem}\u0000${job.sourceJobRef}`;
|
|
103
144
|
const valueKey = (job, fieldId) => `${job.sourceSystem}\u0000${job.sourceJobRef}\u0000${fieldId}`;
|
|
104
145
|
const valueOf = (job, fieldId) => state.values.get(valueKey(job, fieldId)) ?? null;
|
|
105
146
|
const groupable = () => state.fields.filter((f) => f.groupable);
|
|
147
|
+
// The Assignee: the lab's first person custom field (Flow makes one,
|
|
148
|
+
// server/core/custom-fields.js ensureAssignee). The filter "Assignee is
|
|
149
|
+
// Me" keeps the jobs it names the person looking at.
|
|
150
|
+
const assigneeField = () => state.fields.find((f) => f.type === 'person') ?? null;
|
|
151
|
+
// The list's columns a person can choose: Patient only when the
|
|
152
|
+
// installation keeps patient names; Assignee only with a person field.
|
|
153
|
+
const builtInColumns = () => Object.keys(COLUMNS).filter((id) => (id !== 'patient' || state.jobs.some((job) => job.patient))
|
|
154
|
+
&& (id !== 'assignee' || assigneeField()));
|
|
155
|
+
const customColumns = () => state.fields.filter((f) => f.id !== assigneeField()?.id);
|
|
106
156
|
const fieldOfGroup = (group) => groupable().find((f) => f.id === fieldIdOf(group)) ?? null;
|
|
107
157
|
// A work system whose stages Flow can hand a move to (Seazona).
|
|
108
158
|
const movesStages = (job) => state.systemFields.some((f) => f.source === job.sourceSystem);
|
|
@@ -127,13 +177,18 @@ export async function renderJobs(main, ui) {
|
|
|
127
177
|
}, 500);
|
|
128
178
|
}
|
|
129
179
|
|
|
130
|
-
// One toolbar control: a
|
|
131
|
-
//
|
|
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.
|
|
132
183
|
let menuCount = 0;
|
|
133
|
-
function menu(icon, build) {
|
|
184
|
+
function menu(icon, build, { rows = false, clearLabel = 'Clear' } = {}) {
|
|
134
185
|
const id = `fx-jobs-menu-${++menuCount}`;
|
|
135
|
-
const panel = h('div', { class: 'fx-pop'
|
|
136
|
-
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?.());
|
|
137
192
|
const fill = () => panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
|
|
138
193
|
panel.addEventListener('toggle', (event) => {
|
|
139
194
|
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
@@ -144,10 +199,15 @@ export async function renderJobs(main, ui) {
|
|
|
144
199
|
panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
145
200
|
});
|
|
146
201
|
return {
|
|
147
|
-
node: [
|
|
148
|
-
|
|
202
|
+
node: [wrap, panel],
|
|
203
|
+
wrap,
|
|
204
|
+
fill: () => { if (panel.matches(':popover-open')) fill(); },
|
|
205
|
+
set(label, on, clearIt = null) {
|
|
149
206
|
button.replaceChildren(svg(icon), h('span', {}, label));
|
|
207
|
+
wrap.classList.toggle('fx-chipset--on', on);
|
|
150
208
|
button.classList.toggle('fx-chip--on', on);
|
|
209
|
+
onClear = clearIt;
|
|
210
|
+
clear.hidden = !clearIt;
|
|
151
211
|
},
|
|
152
212
|
};
|
|
153
213
|
}
|
|
@@ -156,55 +216,257 @@ export async function renderJobs(main, ui) {
|
|
|
156
216
|
const option = (text, checked, onclick, role = 'menuitemradio') => h('button', {
|
|
157
217
|
type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
|
|
158
218
|
}, h('span', { class: 'fx-pop__tick' }, checked ? svg(TICK_ICON) : null), h('span', {}, text));
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
219
|
+
const DATE_WORDS = ['Oldest first', 'Newest first'];
|
|
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' });
|
|
258
|
+
|
|
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 });
|
|
169
267
|
const sortMenu = menu(SORT_ICON, (redraw) => {
|
|
170
|
-
const
|
|
268
|
+
const { sorts } = state.view;
|
|
269
|
+
const unused = sortOptions().flatMap((o) => o.options ?? [o]).filter((o) => !sorts.some((s) => s.by === o.value));
|
|
171
270
|
return [
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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),
|
|
177
285
|
];
|
|
178
|
-
});
|
|
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
|
+
}
|
|
179
396
|
const filterMenu = menu(FILTER_ICON, (redraw) => {
|
|
180
|
-
const
|
|
397
|
+
const { filters, match } = state.view;
|
|
398
|
+
const fields = filterFields();
|
|
181
399
|
return [
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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' });
|
|
426
|
+
|
|
427
|
+
// Columns (the list only): Client always shows; each other column can be
|
|
428
|
+
// shown or hidden. Custom fields are columns too.
|
|
429
|
+
const heading = (text) => h('p', { class: 'fx-pop__head' }, text);
|
|
430
|
+
const columnsMenu = menu(COLUMNS_ICON, (redraw) => {
|
|
431
|
+
const on = new Set(state.view.columns);
|
|
432
|
+
const flip = (id) => {
|
|
433
|
+
const next = on.has(id) ? state.view.columns.filter((c) => c !== id) : [...state.view.columns, id];
|
|
434
|
+
change({ columns: next });
|
|
435
|
+
redraw();
|
|
436
|
+
};
|
|
437
|
+
const custom = customColumns();
|
|
438
|
+
return [
|
|
439
|
+
heading('Columns'),
|
|
440
|
+
option('Client', true, () => {}, 'menuitemcheckbox'),
|
|
441
|
+
...builtInColumns().map((id) => option(COLUMNS[id], on.has(id), () => flip(id), 'menuitemcheckbox')),
|
|
442
|
+
custom.length ? h('div', { class: 'fx-pop__sep' }) : null,
|
|
443
|
+
custom.length ? heading('Custom fields') : null,
|
|
444
|
+
...custom.map((f) => option(f.name, on.has(fieldGroup(f.id)), () => flip(fieldGroup(f.id)), 'menuitemcheckbox')),
|
|
196
445
|
];
|
|
197
446
|
});
|
|
198
|
-
controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node);
|
|
447
|
+
controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node, ...columnsMenu.node);
|
|
199
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';
|
|
200
451
|
function drawControls() {
|
|
201
|
-
const { group,
|
|
452
|
+
const { group, sorts, layout } = state.view;
|
|
453
|
+
const list = layout === 'list';
|
|
202
454
|
const field = fieldOfGroup(group);
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
455
|
+
// The board has no "None": it shows the status lanes.
|
|
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;
|
|
206
466
|
const count = filterCount(state.view);
|
|
207
|
-
filterMenu.set(count ?
|
|
467
|
+
filterMenu.set(count ? `${count} ${count === 1 ? 'Filter' : 'Filters'}` : 'Filter', count > 0, state.view.filters.length ? () => change({ filters: [], match: 'all' }) : null);
|
|
468
|
+
columnsMenu.set('Columns', false);
|
|
469
|
+
columnsMenu.wrap.hidden = !list;
|
|
208
470
|
}
|
|
209
471
|
drawControls();
|
|
210
472
|
|
|
@@ -217,10 +479,15 @@ export async function renderJobs(main, ui) {
|
|
|
217
479
|
return;
|
|
218
480
|
}
|
|
219
481
|
const found = filterJobs(state.jobs.filter((job) => matches(job, state.q)), state.view,
|
|
220
|
-
{
|
|
221
|
-
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));
|
|
222
484
|
main.classList.toggle('fx-main--board', state.view.layout === 'board');
|
|
485
|
+
// A tick on a job the search or filter now hides goes.
|
|
486
|
+
const shown = new Set(found.map(jobKey));
|
|
487
|
+
for (const key of state.ticked) if (!shown.has(key)) state.ticked.delete(key);
|
|
488
|
+
state.found = found;
|
|
223
489
|
host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
|
|
490
|
+
drawBulk();
|
|
224
491
|
}
|
|
225
492
|
|
|
226
493
|
async function load() {
|
|
@@ -296,7 +563,12 @@ export async function renderJobs(main, ui) {
|
|
|
296
563
|
// A group's `drop` says what a card dropped in it changes: a custom
|
|
297
564
|
// field's value, or the job's stage in the work system. No `drop`: cards
|
|
298
565
|
// cannot be dropped there (invoice stage, client, practitioner).
|
|
566
|
+
// "Descending" shows the groups in the other order.
|
|
299
567
|
function grouped(jobs) {
|
|
568
|
+
const groups = groupsOf(jobs);
|
|
569
|
+
return state.view.groupDir === 'desc' ? groups.reverse() : groups;
|
|
570
|
+
}
|
|
571
|
+
function groupsOf(jobs) {
|
|
300
572
|
const { group } = state.view;
|
|
301
573
|
const field = fieldOfGroup(group);
|
|
302
574
|
if (field) {
|
|
@@ -308,9 +580,9 @@ export async function renderJobs(main, ui) {
|
|
|
308
580
|
return { ...g, rank: 'plain', color: lane?.color, drop: lane && !lane.archived ? { kind: 'field', field, value: lane.value } : null };
|
|
309
581
|
});
|
|
310
582
|
}
|
|
311
|
-
if (group === '
|
|
312
|
-
|
|
313
|
-
return groupJobs(jobs, orderStatuses(state.statuses, state.jobs)
|
|
583
|
+
if (group === 'none' && state.view.layout === 'list') return [{ label: null, jobs, rank: 'plain' }];
|
|
584
|
+
if (group === 'status' || group === 'none' || !GROUPS[group]) {
|
|
585
|
+
return groupJobs(jobs, keptStatuses(state.view, orderStatuses(state.statuses, state.jobs)), laneStatusOf)
|
|
314
586
|
.map((g) => ({ ...g, rank: statusRank(g.label), drop: g.label === NO_STATUS ? null : { kind: 'stage', status: g.label } }));
|
|
315
587
|
}
|
|
316
588
|
if (group === 'stage') {
|
|
@@ -569,27 +841,394 @@ export async function renderJobs(main, ui) {
|
|
|
569
841
|
return h('div', { class: 'fx-task__move' }, wait, select);
|
|
570
842
|
}
|
|
571
843
|
|
|
844
|
+
// A column's menu (a click on its head, or a right-click): sort by it,
|
|
845
|
+
// move it, hide it. Client always shows. `column` null is the + menu: the
|
|
846
|
+
// hidden columns, to add one back.
|
|
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
|
+
}
|
|
906
|
+
const columnPop = h('div', { class: 'fx-pop', id: 'fx-jobs-colmenu', popover: 'auto', role: 'menu' });
|
|
907
|
+
document.body.append(columnPop);
|
|
908
|
+
// A popover opened while the right button is still down closes again on
|
|
909
|
+
// its release (light dismiss), so open it after the release.
|
|
910
|
+
function onRightButton(event, open) {
|
|
911
|
+
if (event.buttons & 2) addEventListener('pointerup', () => setTimeout(open), { once: true });
|
|
912
|
+
else open();
|
|
913
|
+
}
|
|
914
|
+
function columnMenu(column, event) {
|
|
915
|
+
const ids = state.view.columns;
|
|
916
|
+
const close = () => columnPop.hidePopover?.();
|
|
917
|
+
const item = (icon, text, run) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); run(); } },
|
|
918
|
+
h('span', { class: 'fx-pop__tick' }, lucide(h, icon)), h('span', {}, text));
|
|
919
|
+
const parts = [];
|
|
920
|
+
if (column) {
|
|
921
|
+
const sort = sortOf(column.id);
|
|
922
|
+
if (sort) {
|
|
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' }));
|
|
926
|
+
}
|
|
927
|
+
const rest = ids.filter((c) => c !== column.id);
|
|
928
|
+
parts.push(item('arrow-left-to-line', 'Move to start', () => change({ columns: [column.id, ...rest] })),
|
|
929
|
+
item('arrow-right-to-line', 'Move to end', () => change({ columns: [...rest, column.id] })));
|
|
930
|
+
if (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)) {
|
|
931
|
+
parts.push(item('settings', 'Edit custom field', () => ui.go('/custom-fields')));
|
|
932
|
+
}
|
|
933
|
+
parts.push(h('div', { class: 'fx-pop__sep' }), item('eye-off', 'Hide column', () => change({ columns: rest })));
|
|
934
|
+
}
|
|
935
|
+
// + lists the hidden columns: a click adds one at the end.
|
|
936
|
+
if (!column) {
|
|
937
|
+
const shown = new Set(ids);
|
|
938
|
+
const hidden = [
|
|
939
|
+
...builtInColumns().filter((id) => !shown.has(id)).map((id) => ({ id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id] })),
|
|
940
|
+
...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
|
|
941
|
+
];
|
|
942
|
+
parts.push(...hidden.map((c) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ columns: [...ids, c.id] }); } }, c.label)));
|
|
943
|
+
if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
|
|
944
|
+
}
|
|
945
|
+
columnPop.replaceChildren(...parts);
|
|
946
|
+
placeColumnPop(event);
|
|
947
|
+
}
|
|
948
|
+
function placeColumnPop(event) {
|
|
949
|
+
columnPop.showPopover?.();
|
|
950
|
+
const x = event.clientX || event.currentTarget?.getBoundingClientRect?.().left || 0;
|
|
951
|
+
const y = event.clientY || event.currentTarget?.getBoundingClientRect?.().bottom || 0;
|
|
952
|
+
columnPop.style.top = `${Math.round(Math.min(y + 4, innerHeight - columnPop.offsetHeight - 8))}px`;
|
|
953
|
+
columnPop.style.left = `${Math.round(Math.max(8, Math.min(x, innerWidth - columnPop.offsetWidth - 8)))}px`;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// The list's columns, in the order the person chose them. One column holds
|
|
957
|
+
// one field; each date has its own column.
|
|
958
|
+
function listColumns() {
|
|
959
|
+
const day = (iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
|
|
960
|
+
const late = (job) => (isLate(job) ? h('span', { class: 'fx-job__date--late', title: 'Past due' }, day(job.dueOn)) : day(job.dueOn));
|
|
961
|
+
const BUILT_IN = {
|
|
962
|
+
case: { kind: 'case', dim: true, cell: (job) => caseNumber(job) ?? '—' },
|
|
963
|
+
practitioner: { kind: 'text', dim: true, cell: (job) => job.practitioner ?? '—' },
|
|
964
|
+
patient: { kind: 'text', dim: true, cell: (job) => job.patient ?? '—' },
|
|
965
|
+
stage: { kind: 'stage', cell: (job) => stagePill(statusOf(job)) },
|
|
966
|
+
assignee: { kind: 'person', cell: (job) => personBadge(job, assigneeField()) },
|
|
967
|
+
ordered: { kind: 'date', dim: true, cell: (job) => day(job.orderedOn) },
|
|
968
|
+
due: { kind: 'date', dim: true, cell: late },
|
|
969
|
+
shipped: { kind: 'date', dim: true, cell: (job) => day(job.shippedOn) },
|
|
970
|
+
total: { kind: 'total', num: true, strong: true, cell: (job) => (job.totalCents == null ? '—' : formatCents(job.totalCents)) },
|
|
971
|
+
};
|
|
972
|
+
const allowed = new Set(builtInColumns());
|
|
973
|
+
return state.view.columns.map((id) => {
|
|
974
|
+
if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
|
|
975
|
+
const field = customColumns().find((f) => fieldGroup(f.id) === id);
|
|
976
|
+
if (!field) return null;
|
|
977
|
+
const cell = field.type === 'person' ? (job) => personBadge(job, field)
|
|
978
|
+
: field.type === 'date' ? (job) => day(valueOf(job, field.id)?.value ?? null) : (job) => fieldText(job, field);
|
|
979
|
+
return { id, label: field.name, kind: field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text', num: field.type === 'number', dim: true, cell };
|
|
980
|
+
}).filter(Boolean);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// A person custom field's value as a badge (initials), the name on hover.
|
|
984
|
+
function personBadge(job, field) {
|
|
985
|
+
const value = field ? valueOf(job, field.id)?.value ?? null : null;
|
|
986
|
+
if (!value) return h('span', { class: 'fx-dim' }, '—');
|
|
987
|
+
const name = state.people.find((p) => p.id === value)?.name ?? 'Former person';
|
|
988
|
+
return h('span', { class: 'fx-avatar fx-avatar--sm', role: 'img', 'aria-label': name, title: name }, initialsOf(name));
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// A job's custom field value as text.
|
|
992
|
+
function fieldText(job, field) {
|
|
993
|
+
const value = field ? valueOf(job, field.id)?.value ?? null : null;
|
|
994
|
+
if (value == null || value === false) return '—';
|
|
995
|
+
if (field.type === 'checkbox') return 'Yes';
|
|
996
|
+
if (field.type === 'person') return state.people.find((p) => p.id === value)?.name ?? 'Former person';
|
|
997
|
+
if (field.type === 'dropdown') return field.options?.find((o) => o.id === value)?.name ?? '—';
|
|
998
|
+
if (field.type === 'date') return showDate(value);
|
|
999
|
+
return String(value);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
572
1002
|
// The list: one block per group, with the group's label over its rows.
|
|
1003
|
+
// A tick column at the left; a group's head has a tick for every job in
|
|
1004
|
+
// the group, and a click on the head folds the group or opens it. Grouped
|
|
1005
|
+
// by None, it is one plain list with no heads.
|
|
573
1006
|
function list(groups, count) {
|
|
574
1007
|
if (!count) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
|
|
1008
|
+
const shownGroups = groups.filter((g) => g.jobs.length);
|
|
1009
|
+
const folded = new Set(state.folded[state.view.group] ?? []);
|
|
1010
|
+
const allJobs = shownGroups.flatMap((g) => g.jobs);
|
|
1011
|
+
const boxes = [];
|
|
1012
|
+
// A tick box for a set of jobs: ticked when all are, half when some are.
|
|
1013
|
+
const setBox = (jobs, label) => {
|
|
1014
|
+
const box = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': label, onclick: (event) => event.stopPropagation() });
|
|
1015
|
+
const sync = () => {
|
|
1016
|
+
const on = jobs.filter((job) => state.ticked.has(jobKey(job))).length;
|
|
1017
|
+
box.checked = on > 0 && on === jobs.length;
|
|
1018
|
+
box.indeterminate = on > 0 && on < jobs.length;
|
|
1019
|
+
};
|
|
1020
|
+
box.addEventListener('change', () => {
|
|
1021
|
+
for (const job of jobs) { if (box.checked) state.ticked.add(jobKey(job)); else state.ticked.delete(jobKey(job)); }
|
|
1022
|
+
syncAll();
|
|
1023
|
+
});
|
|
1024
|
+
boxes.push(sync);
|
|
1025
|
+
sync();
|
|
1026
|
+
return box;
|
|
1027
|
+
};
|
|
1028
|
+
const syncAll = () => { for (const sync of boxes) sync(); drawBulk(); };
|
|
1029
|
+
const tickCell = (box) => h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, box);
|
|
1030
|
+
const columns = listColumns();
|
|
575
1031
|
const row = (job) => h('tr', {
|
|
576
1032
|
class: 'fx-row-link', tabindex: '0',
|
|
577
1033
|
onclick: () => openJob(job),
|
|
578
|
-
onkeydown: (event) => { if (event.key === 'Enter') openJob(job); },
|
|
1034
|
+
onkeydown: (event) => { if (event.key === 'Enter' && event.target === event.currentTarget) openJob(job); },
|
|
579
1035
|
},
|
|
580
|
-
|
|
581
|
-
h('td', { class: 'fx-
|
|
582
|
-
h('td', {}
|
|
583
|
-
h('td', { class: 'fx-
|
|
584
|
-
|
|
1036
|
+
tickCell(setBox([job], `Tick ${clientOf(job)}${caseNumber(job) ? `, case ${caseNumber(job)}` : ''}`)),
|
|
1037
|
+
h('td', { class: 'fx-c-client' }, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
|
|
1038
|
+
columns.map((c) => h('td', { class: `fx-c-${c.kind}${c.num ? ' fx-num' : ''}${c.dim ? ' fx-dim' : ''}${c.strong ? ' fx-strong' : ''}`, 'data-label': c.label }, c.cell(job))),
|
|
1039
|
+
h('td', { class: 'fx-c-end' }));
|
|
1040
|
+
const fold = (label) => {
|
|
1041
|
+
const next = new Set(state.folded[state.view.group] ?? []);
|
|
1042
|
+
if (next.has(label)) next.delete(label); else next.add(label);
|
|
1043
|
+
state.folded = { ...state.folded, [state.view.group]: [...next] };
|
|
1044
|
+
saveFolded(state.folded);
|
|
1045
|
+
draw();
|
|
1046
|
+
};
|
|
1047
|
+
const head = ({ label, jobs, rank, color }) => {
|
|
1048
|
+
const open = !folded.has(label);
|
|
1049
|
+
const toggle = h('button', {
|
|
1050
|
+
type: 'button', class: 'fx-table__fold', 'aria-expanded': String(open), 'aria-label': `${label}, ${jobs.length} ${jobs.length === 1 ? 'job' : 'jobs'}. ${open ? 'Fold' : 'Open'} the group`,
|
|
1051
|
+
onclick: (event) => { event.stopPropagation(); fold(label); },
|
|
1052
|
+
}, svg(CHEVRON_ICON),
|
|
1053
|
+
color ? h('span', { class: 'fx-cf__opt fx-board__lane', 'data-color': color }, label, h('span', { class: 'fx-stage__count' }, String(jobs.length))) : stagePill(label, jobs.length, rank));
|
|
1054
|
+
return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: () => fold(label) },
|
|
1055
|
+
tickCell(setBox(jobs, `Tick every job in ${label}`)),
|
|
1056
|
+
h('td', { colspan: String(columns.length + 2) }, h('span', { class: 'fx-table__group-row' }, toggle, h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))))));
|
|
1057
|
+
};
|
|
585
1058
|
return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
|
|
586
1059
|
h('thead', {}, h('tr', {},
|
|
587
|
-
h('th', {
|
|
588
|
-
h('th', { class: 'fx-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
1060
|
+
h('th', { class: 'fx-tick-cell' }, setBox(allJobs, 'Tick every job shown')),
|
|
1061
|
+
h('th', {}, h('span', { class: 'fx-th-cell' }, h('span', { class: 'fx-th fx-th--fixed' }, 'Client'), sortButton('client', 'Client'))),
|
|
1062
|
+
columns.map((c) => h('th', { class: c.num ? 'fx-num' : null, oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => columnMenu(c, event)); } },
|
|
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)))),
|
|
1066
|
+
h('th', { class: 'fx-th-add' }, h('button', {
|
|
1067
|
+
type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => columnMenu(null, event),
|
|
1068
|
+
}, '+')))),
|
|
1069
|
+
shownGroups.map((g) => h('tbody', {},
|
|
1070
|
+
g.label == null ? null : head(g),
|
|
1071
|
+
g.label != null && folded.has(g.label) ? null : g.jobs.map(row)))));
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// ── Bulk actions ─────────────────────────────────────────────────────────
|
|
1075
|
+
// The bar floats at the foot while list rows are ticked (bulk-bar.js, as on
|
|
1076
|
+
// Invoices). Move and Ready are the main steps; Copy and Export take the
|
|
1077
|
+
// ticked rows out; More has them all, a field per custom field, and the
|
|
1078
|
+
// Selection steps.
|
|
1079
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
1080
|
+
const tickedJobs = () => (state.found ?? []).filter((job) => state.ticked.has(jobKey(job)));
|
|
1081
|
+
const setTicks = (jobs) => { state.ticked = new Set(jobs.map(jobKey)); draw(); };
|
|
1082
|
+
|
|
1083
|
+
function drawBulk() {
|
|
1084
|
+
const rows = state.view.layout === 'list' ? tickedJobs() : [];
|
|
1085
|
+
bulk.draw({ count: rows.length, onClear: () => setTicks([]), ...bulkActions(rows) });
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function bulkActions(rows) {
|
|
1089
|
+
if (!rows.length) return { groups: [], more: [] };
|
|
1090
|
+
const status = [{ icon: 'arrow-right-left', label: 'Move', title: 'Move to a stage', run: () => moveMany(rows) }];
|
|
1091
|
+
if (state.fields.length) status.push({ icon: 'tag', label: 'Custom field', title: 'Set a custom field', run: () => setMany(rows) });
|
|
1092
|
+
const workable = rows.filter((job) => job.obligation?.view === 'in_work');
|
|
1093
|
+
if (workable.length) status.push({ icon: 'check-check', label: 'Ready', title: 'Mark ready to invoice', run: () => readyMany(workable) });
|
|
1094
|
+
const draftable = state.canDraft ? rows.filter((job) => job.obligation?.view === 'ready' && !job.obligation.invoiceId) : [];
|
|
1095
|
+
if (draftable.length) status.push({ icon: 'file-plus', label: 'Draft', title: 'Draft invoices', run: () => draftMany(draftable) });
|
|
1096
|
+
const fields = state.fields.map((field) => ({ icon: 'tag', label: field.name, title: `Set ${field.name}`, run: () => setMany(rows, field.id) }));
|
|
1097
|
+
const apply = [
|
|
1098
|
+
{ icon: 'copy', label: 'Copy', iconOnly: true, title: 'Copy case numbers', run: () => copyLines(rows.map((job) => caseNumber(job) ?? clientOf(job))) },
|
|
1099
|
+
{ icon: 'download', label: 'Export', iconOnly: true, title: 'Export CSV', run: () => exportCsv(rows) },
|
|
1100
|
+
];
|
|
1101
|
+
const all = state.found ?? [];
|
|
1102
|
+
const selection = [
|
|
1103
|
+
{ icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
|
|
1104
|
+
{ icon: 'square-check', label: 'Invert ticks', run: () => setTicks(all.filter((job) => !state.ticked.has(jobKey(job)))) },
|
|
1105
|
+
{ icon: 'square-x', label: 'Clear ticks', run: () => setTicks([]) },
|
|
1106
|
+
];
|
|
1107
|
+
return {
|
|
1108
|
+
groups: [status, apply],
|
|
1109
|
+
more: [
|
|
1110
|
+
{ head: 'Change stage', actions: status },
|
|
1111
|
+
{ head: 'Set a custom field', actions: fields },
|
|
1112
|
+
{ head: 'Apply an action', actions: apply },
|
|
1113
|
+
{ head: 'Selection', actions: selection },
|
|
1114
|
+
],
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Move the ticked jobs to one stage. A work system's own stages
|
|
1119
|
+
// (Seazona) cannot be moved from Flow in bulk yet, so this says so; a
|
|
1120
|
+
// lab's own stages are custom fields (Field). Someone who needs it can
|
|
1121
|
+
// report it (Report a problem).
|
|
1122
|
+
async function moveMany(jobs) {
|
|
1123
|
+
const systems = [...new Set(jobs.map((job) => SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem))].join(' and ');
|
|
1124
|
+
await ui.dialog({
|
|
1125
|
+
title: 'Not yet available',
|
|
1126
|
+
body: h('p', {}, `Moving ${plural(jobs.length, 'job', 'jobs')} to a ${systems} stage at once is not yet available.`),
|
|
1127
|
+
confirm: 'OK',
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// One step on each job's invoice record, one job after the other.
|
|
1132
|
+
async function eachJob(jobs, step) {
|
|
1133
|
+
let done = 0;
|
|
1134
|
+
const refused = [];
|
|
1135
|
+
for (const job of jobs) {
|
|
1136
|
+
try { await step(job); done += 1; } catch (e) { refused.push(e); }
|
|
1137
|
+
}
|
|
1138
|
+
return { done, refused };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
async function readyMany(jobs) {
|
|
1142
|
+
const { done, refused } = await eachJob(jobs, (job) => flow(`/api/invoicing/jobs/${job.obligation.id}/ready`, { method: 'POST', body: JSON.stringify({ ready: true }) }));
|
|
1143
|
+
ui.toast?.(refused.length ? `${done} ready to invoice. ${refused[0].message}` : `${plural(done, 'job', 'jobs')} ready to invoice.`, refused.length ? 'danger' : 'success');
|
|
1144
|
+
state.ticked.clear();
|
|
1145
|
+
await load();
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
async function draftMany(jobs) {
|
|
1149
|
+
const ok = await ui.dialog({
|
|
1150
|
+
title: `Draft ${plural(jobs.length, 'invoice', 'invoices')}?`,
|
|
1151
|
+
body: h('p', {}, 'Flow makes a draft invoice for each job. The drafts stay in Flow until a person checks them.'),
|
|
1152
|
+
confirm: `Draft ${jobs.length}`,
|
|
1153
|
+
});
|
|
1154
|
+
if (!ok) return;
|
|
1155
|
+
const { done, refused } = await eachJob(jobs, (job) => flow(`/api/invoicing/jobs/${job.obligation.id}/draft`, { method: 'POST', body: '{}' }));
|
|
1156
|
+
ui.toast?.(refused.length ? `${done} drafted. ${plural(refused.length, 'job was', 'jobs were')} not drafted: open ${refused.length === 1 ? 'it' : 'them'} to see why.` : `${plural(done, 'draft invoice', 'draft invoices')} made.`, refused.length ? 'danger' : 'success');
|
|
1157
|
+
state.ticked.clear();
|
|
1158
|
+
await load();
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Set one custom field on the ticked jobs, in Flow only: choose the
|
|
1162
|
+
// custom field, then its value. `fieldId` starts on that custom field.
|
|
1163
|
+
// A dropdown, person or checkbox has a list of values; a date, number or
|
|
1164
|
+
// text has a box. An empty box clears the value.
|
|
1165
|
+
async function setMany(jobs, fieldId = null) {
|
|
1166
|
+
const fields = state.fields;
|
|
1167
|
+
if (!fields.length) return;
|
|
1168
|
+
const lanesOf = (field) => fieldLanes(field, { people: state.people, jobs: state.jobs, valueOf: (job) => valueOf(job, field.id)?.value ?? null }).filter((lane) => !lane.archived);
|
|
1169
|
+
const listed = (field) => field.groupable;
|
|
1170
|
+
const pickField = h('select', { class: 'fx-select', name: 'field', 'aria-label': 'Custom field' },
|
|
1171
|
+
fields.map((f) => h('option', { value: f.id, selected: f.id === fieldId }, f.name)));
|
|
1172
|
+
const valueHost = h('div', {});
|
|
1173
|
+
const fieldOf = (id) => fields.find((f) => f.id === id) ?? fields[0];
|
|
1174
|
+
const fillValue = () => {
|
|
1175
|
+
const field = fieldOf(pickField.value);
|
|
1176
|
+
const control = listed(field)
|
|
1177
|
+
? h('select', { class: 'fx-select', name: 'value', 'aria-label': 'Value' }, lanesOf(field).map((lane, i) => h('option', { value: String(i) }, lane.label)))
|
|
1178
|
+
: h('input', {
|
|
1179
|
+
class: 'fx-input', name: 'value', 'aria-label': 'Value',
|
|
1180
|
+
type: field.type === 'date' ? 'date' : field.type === 'number' ? 'number' : 'text', step: field.type === 'number' ? 'any' : null, maxlength: field.type === 'text' ? '500' : null,
|
|
1181
|
+
});
|
|
1182
|
+
valueHost.replaceChildren(h('label', { class: 'fx-field' }, h('span', {}, 'Value'), control));
|
|
1183
|
+
};
|
|
1184
|
+
pickField.addEventListener('change', fillValue);
|
|
1185
|
+
fillValue();
|
|
1186
|
+
const data = await ui.dialog({
|
|
1187
|
+
title: `Set a custom field on ${plural(jobs.length, 'job', 'jobs')}`,
|
|
1188
|
+
body: h('div', { class: 'fx-fields' }, h('label', { class: 'fx-field' }, h('span', {}, 'Custom field'), pickField), valueHost),
|
|
1189
|
+
confirm: 'Set',
|
|
1190
|
+
});
|
|
1191
|
+
if (!data) return;
|
|
1192
|
+
const field = fields.find((f) => f.id === data.get('field'));
|
|
1193
|
+
if (!field) return;
|
|
1194
|
+
let value;
|
|
1195
|
+
let shown;
|
|
1196
|
+
if (listed(field)) {
|
|
1197
|
+
const lane = lanesOf(field)[Number(data.get('value'))];
|
|
1198
|
+
if (!lane) return;
|
|
1199
|
+
({ value } = lane);
|
|
1200
|
+
shown = lane.label;
|
|
1201
|
+
} else {
|
|
1202
|
+
const raw = String(data.get('value') ?? '').trim();
|
|
1203
|
+
value = raw === '' ? null : field.type === 'number' ? Number(raw) : raw;
|
|
1204
|
+
shown = raw === '' ? 'empty' : field.type === 'date' ? fullDate(raw) : raw;
|
|
1205
|
+
}
|
|
1206
|
+
for (const job of jobs) await setValue(job, field, value);
|
|
1207
|
+
ui.toast?.(`${field.name} set to ${shown} on ${plural(jobs.length, 'job', 'jobs')}.`, 'success');
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function copyLines(lines) {
|
|
1211
|
+
try {
|
|
1212
|
+
await navigator.clipboard.writeText(lines.join('\n'));
|
|
1213
|
+
ui.toast?.(`${plural(lines.length, 'line', 'lines')} copied.`, 'success');
|
|
1214
|
+
} catch {
|
|
1215
|
+
ui.toast?.('The browser did not allow a copy.', 'danger');
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// The ticked rows as a CSV file, with the columns on the screen.
|
|
1220
|
+
function exportCsv(jobs) {
|
|
1221
|
+
const cell = (v) => (/[",\n]/.test(String(v ?? '')) ? `"${String(v).replace(/"/g, '""')}"` : String(v ?? ''));
|
|
1222
|
+
const lines = [['Case', 'Client', 'Practitioner', 'Stage', 'Due', 'Ordered', 'Total']]
|
|
1223
|
+
.concat(jobs.map((job) => [caseNumber(job) ?? '', clientOf(job), job.practitioner ?? '', statusOf(job), job.dueOn ?? '', job.orderedOn ?? '',
|
|
1224
|
+
job.totalCents == null ? '' : (job.totalCents / 100).toFixed(2)]));
|
|
1225
|
+
const url = URL.createObjectURL(new Blob([lines.map((l) => l.map(cell).join(',')).join('\r\n')], { type: 'text/csv' }));
|
|
1226
|
+
const a = h('a', { href: url, download: `jobs-${today()}.csv` });
|
|
1227
|
+
document.body.append(a);
|
|
1228
|
+
a.click();
|
|
1229
|
+
a.remove();
|
|
1230
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
1231
|
+
ui.toast?.(`${plural(jobs.length, 'job', 'jobs')} exported.`, 'success');
|
|
593
1232
|
}
|
|
594
1233
|
|
|
595
1234
|
// What a person can do with the job's invoice from here.
|
|
@@ -667,6 +1306,7 @@ export async function renderJobs(main, ui) {
|
|
|
667
1306
|
const o = job.obligation;
|
|
668
1307
|
const stage = invoiceStage(o);
|
|
669
1308
|
const found = problemsOf(job);
|
|
1309
|
+
const choose = found.find((f) => f.go === 'choose') ?? null;
|
|
670
1310
|
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
671
1311
|
const status = job.status ?? NO_STATUS;
|
|
672
1312
|
const late = isLate(job);
|
|
@@ -678,10 +1318,37 @@ export async function renderJobs(main, ui) {
|
|
|
678
1318
|
const invoiceText = o?.invoiceId
|
|
679
1319
|
? link({ href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } },
|
|
680
1320
|
`${o.invoiceStatus === 'draft' ? 'Draft' : 'Invoice'} ${o.invoiceNumber ?? ''}`.trim() + (stage ? ` · ${INVOICE_STAGES[stage]}` : ''))
|
|
681
|
-
:
|
|
682
|
-
|
|
1321
|
+
: choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
|
|
1322
|
+
: stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
|
|
1323
|
+
const rest = found.filter((f) => f.go !== 'choose');
|
|
1324
|
+
const fixBox = rest.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
|
|
683
1325
|
h('h3', {}, 'Fix'),
|
|
684
|
-
|
|
1326
|
+
rest.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, [f.text, f.todo].filter(Boolean).join(' ')), fixLink(f.go)))) : null;
|
|
1327
|
+
// No invoice, and the match is unclear: one line and two buttons. The
|
|
1328
|
+
// possible matches are compared on the Reconcile page, not here.
|
|
1329
|
+
const chooseBox = choose ? h('section', { class: 'fx-task__choose', 'aria-label': 'Invoice' },
|
|
1330
|
+
svg(WARN_ICON),
|
|
1331
|
+
h('span', {}, choose.text),
|
|
1332
|
+
h('div', { class: 'fx-task__choices' },
|
|
1333
|
+
state.canDraft ? h('button', { class: 'fx-btn fx-btn--primary', type: 'button', onclick: (event) => makeInvoice(event.currentTarget) }, 'Make invoice') : null,
|
|
1334
|
+
h('a', { class: 'fx-btn', href: `/reconcile/${o.id}`, onclick: (event) => { close(); ui.go(event); } }, 'Reconcile'))) : null;
|
|
1335
|
+
// "Make invoice": the person says the job has no invoice yet, then Flow
|
|
1336
|
+
// drafts it (the live check before the draft still runs).
|
|
1337
|
+
async function makeInvoice(button) {
|
|
1338
|
+
if (button) button.disabled = true;
|
|
1339
|
+
try {
|
|
1340
|
+
await flow(`/api/invoicing/jobs/${o.id}/no-invoice`, { method: 'POST', body: '{}' });
|
|
1341
|
+
const { invoice } = await flow(`/api/invoicing/jobs/${o.id}/draft`, { method: 'POST', body: '{}' });
|
|
1342
|
+
ui.toast('Draft invoice made.', 'success');
|
|
1343
|
+
close();
|
|
1344
|
+
ui.go(`/invoices/${invoice.id}`);
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
const reasons = error.details?.reasons;
|
|
1347
|
+
ui.toast(reasons?.length ? `Not drafted: ${reasons.join('; ')}.` : error.message, 'danger');
|
|
1348
|
+
close();
|
|
1349
|
+
await load();
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
685
1352
|
// Each fix's link: Flow's own invoice, the job in the work system, or
|
|
686
1353
|
// the Flow page where the fix is made (job-board.js GO).
|
|
687
1354
|
function fixLink(go) {
|
|
@@ -718,9 +1385,10 @@ export async function renderJobs(main, ui) {
|
|
|
718
1385
|
// The job links to Flow's invoice only. The invoice page shows its
|
|
719
1386
|
// link to an accounting system (its Linked card).
|
|
720
1387
|
field('Invoice', invoiceText)),
|
|
1388
|
+
chooseBox,
|
|
721
1389
|
fixBox,
|
|
722
1390
|
state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
|
|
723
|
-
h('h3', {}, '
|
|
1391
|
+
h('h3', {}, 'Custom fields'),
|
|
724
1392
|
h('dl', { class: 'fx-task__fields' }, state.fields.map((f) => h('div', {}, h('dt', {}, f.name), h('dd', {}, fieldControl(job, f)))))) : null,
|
|
725
1393
|
movesStages(job) ? h('section', { class: 'fx-task__custom', 'aria-label': `${system} stage` },
|
|
726
1394
|
h('h3', {}, `${system} stage`), stageControl(job)) : null,
|
|
@@ -745,7 +1413,7 @@ export async function renderJobs(main, ui) {
|
|
|
745
1413
|
element.addEventListener('close', () => element.remove());
|
|
746
1414
|
document.body.append(element);
|
|
747
1415
|
element.showModal();
|
|
748
|
-
if (atFix && fixBox) fixBox.scrollIntoView({ block: 'start' });
|
|
1416
|
+
if (atFix && (chooseBox ?? fixBox)) (chooseBox ?? fixBox).scrollIntoView({ block: 'start' });
|
|
749
1417
|
if (!o) return;
|
|
750
1418
|
const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
|
|
751
1419
|
flow(`/api/invoicing/jobs/${o.id}/activity`).then(({ events }) => {
|