@arcforgelabs/flow 2026.10.37 → 2026.10.38
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 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/custom-fields.js +2 -1
- package/server/api/invoicing.js +50 -1
- package/server/core/custom-fields.js +18 -0
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +80 -1
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +1 -1
- 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/meta/_journal.json +7 -0
- package/server/integrations/xero/reconcile.js +17 -11
- package/server/integrations/xero/routes.js +42 -0
- package/server/integrations/xero/settings.js +6 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +54 -0
- package/server/ui/bulk-bar.js +4 -3
- package/server/ui/flow.css +141 -0
- package/server/ui/job-board.js +8 -6
- package/server/ui/job-view.js +30 -7
- package/server/ui/jobs.js +443 -26
- 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,23 @@
|
|
|
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, laneOf, normaliseJobsView, NOT_SET, sortJobs, SORTS,
|
|
22
|
+
COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, filterCount, filterJobs, groupJobs, GROUPS, laneOf, normaliseJobsView, NOT_SET, sortJobs, SORTS,
|
|
21
23
|
} from '/assets/ui/job-view.js';
|
|
22
24
|
|
|
23
25
|
const LAYOUT_KEY = 'flow_jobs_layout';
|
|
26
|
+
const FOLDED_KEY = 'flow_jobs_folded';
|
|
24
27
|
const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
|
|
25
28
|
// A job's events (server/core/ledger.js), in plain words.
|
|
26
29
|
const ACTIVITY = {
|
|
27
30
|
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',
|
|
31
|
+
in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused', invoice_linked: 'Linked to an invoice',
|
|
29
32
|
accounting_checked: 'Checked in the accounting system', payment_recorded: 'Payment recorded', paid: 'Paid in full',
|
|
30
33
|
held: 'Put on hold', review: 'Sent to review', released: 'Hold removed',
|
|
31
34
|
status_moved_back: 'The job status moved back in the lab system', source_lines_used: 'New job lines used on the draft',
|
|
@@ -39,6 +42,23 @@ const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4
|
|
|
39
42
|
|
|
40
43
|
const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
|
|
41
44
|
const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
|
|
45
|
+
// A list date, short: Today, Tomorrow or Yesterday, else day/month (and
|
|
46
|
+
// year when it is not this year).
|
|
47
|
+
const shortDate = (iso) => {
|
|
48
|
+
if (!iso) return '—';
|
|
49
|
+
const at = new Date(`${iso}T00:00:00`);
|
|
50
|
+
const now = new Date();
|
|
51
|
+
const days = Math.round((at - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
|
|
52
|
+
if (days === 0) return 'Today';
|
|
53
|
+
if (days === 1) return 'Tomorrow';
|
|
54
|
+
if (days === -1) return 'Yesterday';
|
|
55
|
+
return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
|
|
56
|
+
};
|
|
57
|
+
// A person's badge: their initials in a circle; the name on hover.
|
|
58
|
+
const initialsOf = (name) => {
|
|
59
|
+
const parts = (name ?? '?').split(/[\s._-]+/).filter(Boolean);
|
|
60
|
+
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
|
|
61
|
+
};
|
|
42
62
|
const clientOf = (job) => job.clientName ?? 'No client';
|
|
43
63
|
// The one date a card shows: when the job is due, else when it was ordered.
|
|
44
64
|
const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
|
|
@@ -51,6 +71,18 @@ function saveLayout(layout) {
|
|
|
51
71
|
try { localStorage.setItem(LAYOUT_KEY, layout); } catch { /* the choice is only a convenience */ }
|
|
52
72
|
}
|
|
53
73
|
|
|
74
|
+
// The list's folded groups, by group key, in this browser only.
|
|
75
|
+
function readFolded() {
|
|
76
|
+
try {
|
|
77
|
+
const value = JSON.parse(localStorage.getItem(FOLDED_KEY) ?? '{}');
|
|
78
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
79
|
+
} catch { return {}; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function saveFolded(folded) {
|
|
83
|
+
try { localStorage.setItem(FOLDED_KEY, JSON.stringify(folded)); } catch { /* a convenience only */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
54
86
|
// The local calendar day, as yyyy-mm-dd, to mark late jobs.
|
|
55
87
|
const today = () => {
|
|
56
88
|
const now = new Date();
|
|
@@ -61,8 +93,10 @@ const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="
|
|
|
61
93
|
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
94
|
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
95
|
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>';
|
|
96
|
+
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
97
|
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
98
|
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>';
|
|
99
|
+
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
100
|
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
101
|
|
|
68
102
|
export async function renderJobs(main, ui) {
|
|
@@ -81,16 +115,21 @@ export async function renderJobs(main, ui) {
|
|
|
81
115
|
fields: [], systemFields: [], people: [], values: new Map(),
|
|
82
116
|
// Each work system's "Ask before stage moves", read on the first move.
|
|
83
117
|
askBefore: new Map(),
|
|
118
|
+
// The list's ticked jobs (by jobKey), and its folded groups by group key.
|
|
119
|
+
ticked: new Set(), folded: readFolded(),
|
|
84
120
|
};
|
|
85
121
|
const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
|
|
86
122
|
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
123
|
const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
|
|
88
124
|
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
|
|
125
|
+
// In the list, ticked rows get the same floating bar as Invoices.
|
|
126
|
+
const bulk = bulkBar(ui, { label: 'Ticked jobs' });
|
|
89
127
|
main.classList.add('fx-main--wide');
|
|
90
128
|
main.append(
|
|
91
129
|
pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
|
|
92
130
|
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, search, toggle)),
|
|
93
|
-
host
|
|
131
|
+
host,
|
|
132
|
+
bulk.element);
|
|
94
133
|
|
|
95
134
|
const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
|
|
96
135
|
type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
|
|
@@ -100,9 +139,23 @@ export async function renderJobs(main, ui) {
|
|
|
100
139
|
search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
|
|
101
140
|
|
|
102
141
|
const problemsOf = (job) => fixes(job.obligation);
|
|
142
|
+
const jobKey = (job) => `${job.sourceSystem}\u0000${job.sourceJobRef}`;
|
|
103
143
|
const valueKey = (job, fieldId) => `${job.sourceSystem}\u0000${job.sourceJobRef}\u0000${fieldId}`;
|
|
104
144
|
const valueOf = (job, fieldId) => state.values.get(valueKey(job, fieldId)) ?? null;
|
|
105
145
|
const groupable = () => state.fields.filter((f) => f.groupable);
|
|
146
|
+
// The Assignee: the lab's first person custom field (Flow makes one,
|
|
147
|
+
// server/core/custom-fields.js ensureAssignee). "Assignee: me" keeps the
|
|
148
|
+
// jobs it names the person looking at.
|
|
149
|
+
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
|
+
// The list's columns a person can choose: Patient only when the
|
|
155
|
+
// installation keeps patient names; Assignee only with a person field.
|
|
156
|
+
const builtInColumns = () => Object.keys(COLUMNS).filter((id) => (id !== 'patient' || state.jobs.some((job) => job.patient))
|
|
157
|
+
&& (id !== 'assignee' || assigneeField()));
|
|
158
|
+
const customColumns = () => state.fields.filter((f) => f.id !== assigneeField()?.id);
|
|
106
159
|
const fieldOfGroup = (group) => groupable().find((f) => f.id === fieldIdOf(group)) ?? null;
|
|
107
160
|
// A work system whose stages Flow can hand a move to (Seazona).
|
|
108
161
|
const movesStages = (job) => state.systemFields.some((f) => f.source === job.sourceSystem);
|
|
@@ -157,7 +210,8 @@ export async function renderJobs(main, ui) {
|
|
|
157
210
|
type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
|
|
158
211
|
}, h('span', { class: 'fx-pop__tick' }, checked ? svg(TICK_ICON) : null), h('span', {}, text));
|
|
159
212
|
const heading = (text) => h('p', { class: 'fx-pop__head' }, text);
|
|
160
|
-
const
|
|
213
|
+
const DATE_WORDS = ['Oldest first', 'Newest first'];
|
|
214
|
+
const DIR_WORDS = { total: ['Low to high', 'High to low'], due: DATE_WORDS, ordered: DATE_WORDS, shipped: DATE_WORDS, board: ['As the lab system has it', 'Reversed'] };
|
|
161
215
|
|
|
162
216
|
const groupMenu = menu(GROUP_ICON, (_redraw, close) => [
|
|
163
217
|
heading('Group by'),
|
|
@@ -182,6 +236,7 @@ export async function renderJobs(main, ui) {
|
|
|
182
236
|
heading('Show only'),
|
|
183
237
|
option('Late jobs', state.view.late, () => { change({ late: !state.view.late }); redraw(); }, 'menuitemcheckbox'),
|
|
184
238
|
option('Jobs with a problem', state.view.problems, () => { change({ problems: !state.view.problems }); redraw(); }, 'menuitemcheckbox'),
|
|
239
|
+
assigneeField() ? option(`${assigneeField().name}: me`, state.view.mine, () => { change({ mine: !state.view.mine }); redraw(); }, 'menuitemcheckbox') : null,
|
|
185
240
|
h('div', { class: 'fx-pop__sep' }),
|
|
186
241
|
heading('Statuses'),
|
|
187
242
|
...orderStatuses(state.statuses, state.jobs).map((label) => option(label, !hidden.has(label), () => {
|
|
@@ -191,20 +246,43 @@ export async function renderJobs(main, ui) {
|
|
|
191
246
|
redraw();
|
|
192
247
|
}, 'menuitemcheckbox')),
|
|
193
248
|
filterCount(state.view) ? h('div', { class: 'fx-pop__foot' }, h('button', {
|
|
194
|
-
type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick: () => { change({ hide: [], late: false, problems: false }); redraw(); },
|
|
249
|
+
type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick: () => { change({ hide: [], late: false, problems: false, mine: false }); redraw(); },
|
|
195
250
|
}, 'Clear filters')) : null,
|
|
196
251
|
];
|
|
197
252
|
});
|
|
198
|
-
|
|
253
|
+
// Columns (the list only): Client always shows; each other column can be
|
|
254
|
+
// shown or hidden. Custom fields are columns too.
|
|
255
|
+
const columnsMenu = menu(COLUMNS_ICON, (redraw) => {
|
|
256
|
+
const on = new Set(state.view.columns);
|
|
257
|
+
const flip = (id) => {
|
|
258
|
+
const next = on.has(id) ? state.view.columns.filter((c) => c !== id) : [...state.view.columns, id];
|
|
259
|
+
change({ columns: next });
|
|
260
|
+
redraw();
|
|
261
|
+
};
|
|
262
|
+
const custom = customColumns();
|
|
263
|
+
return [
|
|
264
|
+
heading('Columns'),
|
|
265
|
+
option('Client', true, () => {}, 'menuitemcheckbox'),
|
|
266
|
+
...builtInColumns().map((id) => option(COLUMNS[id], on.has(id), () => flip(id), 'menuitemcheckbox')),
|
|
267
|
+
custom.length ? h('div', { class: 'fx-pop__sep' }) : null,
|
|
268
|
+
custom.length ? heading('Custom fields') : null,
|
|
269
|
+
...custom.map((f) => option(f.name, on.has(fieldGroup(f.id)), () => flip(fieldGroup(f.id)), 'menuitemcheckbox')),
|
|
270
|
+
];
|
|
271
|
+
});
|
|
272
|
+
controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node, ...columnsMenu.node);
|
|
199
273
|
|
|
200
274
|
function drawControls() {
|
|
201
275
|
const { group, sort, dir } = state.view;
|
|
202
276
|
const field = fieldOfGroup(group);
|
|
203
|
-
|
|
277
|
+
// The board has no "None": it shows the status lanes.
|
|
278
|
+
const shown = group === 'none' && state.view.layout === 'board' ? 'status' : group;
|
|
279
|
+
groupMenu.set(`Group: ${field?.name ?? GROUPS[shown] ?? GROUPS.status}`, shown !== DEFAULT_VIEW.group && (Boolean(field) || Boolean(GROUPS[shown])));
|
|
204
280
|
const sorted = sort !== DEFAULT_VIEW.sort || dir !== DEFAULT_VIEW.dir;
|
|
205
281
|
sortMenu.set(sorted ? `Sort: ${SORTS[sort]}${dir === 'desc' ? ' ↓' : ''}` : 'Sort', sorted);
|
|
206
282
|
const count = filterCount(state.view);
|
|
207
283
|
filterMenu.set(count ? `Filter · ${count}` : 'Filter', count > 0);
|
|
284
|
+
columnsMenu.set('Columns', false);
|
|
285
|
+
columnsMenu.node[0].hidden = state.view.layout !== 'list';
|
|
208
286
|
}
|
|
209
287
|
drawControls();
|
|
210
288
|
|
|
@@ -217,10 +295,15 @@ export async function renderJobs(main, ui) {
|
|
|
217
295
|
return;
|
|
218
296
|
}
|
|
219
297
|
const found = filterJobs(state.jobs.filter((job) => matches(job, state.q)), state.view,
|
|
220
|
-
{ status: statusOf, late: isLate, problem: (job) => problemsOf(job).length > 0 });
|
|
298
|
+
{ status: statusOf, late: isLate, problem: (job) => problemsOf(job).length > 0, mine: isMine });
|
|
221
299
|
const groups = grouped(sortJobs(found, state.view));
|
|
222
300
|
main.classList.toggle('fx-main--board', state.view.layout === 'board');
|
|
301
|
+
// A tick on a job the search or filter now hides goes.
|
|
302
|
+
const shown = new Set(found.map(jobKey));
|
|
303
|
+
for (const key of state.ticked) if (!shown.has(key)) state.ticked.delete(key);
|
|
304
|
+
state.found = found;
|
|
223
305
|
host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
|
|
306
|
+
drawBulk();
|
|
224
307
|
}
|
|
225
308
|
|
|
226
309
|
async function load() {
|
|
@@ -308,7 +391,8 @@ export async function renderJobs(main, ui) {
|
|
|
308
391
|
return { ...g, rank: 'plain', color: lane?.color, drop: lane && !lane.archived ? { kind: 'field', field, value: lane.value } : null };
|
|
309
392
|
});
|
|
310
393
|
}
|
|
311
|
-
if (group === '
|
|
394
|
+
if (group === 'none' && state.view.layout === 'list') return [{ label: null, jobs, rank: 'plain' }];
|
|
395
|
+
if (group === 'status' || group === 'none' || !GROUPS[group]) {
|
|
312
396
|
const hidden = new Set(state.view.hide);
|
|
313
397
|
return groupJobs(jobs, orderStatuses(state.statuses, state.jobs).filter((label) => !hidden.has(label)), laneStatusOf)
|
|
314
398
|
.map((g) => ({ ...g, rank: statusRank(g.label), drop: g.label === NO_STATUS ? null : { kind: 'stage', status: g.label } }));
|
|
@@ -569,27 +653,331 @@ export async function renderJobs(main, ui) {
|
|
|
569
653
|
return h('div', { class: 'fx-task__move' }, wait, select);
|
|
570
654
|
}
|
|
571
655
|
|
|
656
|
+
// A column's menu (a click on its head, or a right-click): sort by it,
|
|
657
|
+
// move it, hide it. Client always shows. `column` null is the + menu: the
|
|
658
|
+
// hidden columns, to add one back.
|
|
659
|
+
const SORT_OF = { practitioner: 'practitioner', due: 'due', ordered: 'ordered', shipped: 'shipped', total: 'total' };
|
|
660
|
+
const columnPop = h('div', { class: 'fx-pop', id: 'fx-jobs-colmenu', popover: 'auto', role: 'menu' });
|
|
661
|
+
document.body.append(columnPop);
|
|
662
|
+
// A popover opened while the right button is still down closes again on
|
|
663
|
+
// its release (light dismiss), so open it after the release.
|
|
664
|
+
function onRightButton(event, open) {
|
|
665
|
+
if (event.buttons & 2) addEventListener('pointerup', () => setTimeout(open), { once: true });
|
|
666
|
+
else open();
|
|
667
|
+
}
|
|
668
|
+
function columnMenu(column, event) {
|
|
669
|
+
const ids = state.view.columns;
|
|
670
|
+
const close = () => columnPop.hidePopover?.();
|
|
671
|
+
const item = (icon, text, run) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); run(); } },
|
|
672
|
+
h('span', { class: 'fx-pop__tick' }, lucide(h, icon)), h('span', {}, text));
|
|
673
|
+
const parts = [];
|
|
674
|
+
if (column) {
|
|
675
|
+
const sort = SORT_OF[column.id];
|
|
676
|
+
if (sort) {
|
|
677
|
+
const [up, down] = DIR_WORDS[sort] ?? ['A to Z', 'Z to A'];
|
|
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' }));
|
|
680
|
+
}
|
|
681
|
+
const rest = ids.filter((c) => c !== column.id);
|
|
682
|
+
parts.push(item('arrow-left-to-line', 'Move to start', () => change({ columns: [column.id, ...rest] })),
|
|
683
|
+
item('arrow-right-to-line', 'Move to end', () => change({ columns: [...rest, column.id] })));
|
|
684
|
+
if (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)) {
|
|
685
|
+
parts.push(item('settings', 'Edit custom field', () => ui.go('/custom-fields')));
|
|
686
|
+
}
|
|
687
|
+
parts.push(h('div', { class: 'fx-pop__sep' }), item('eye-off', 'Hide column', () => change({ columns: rest })));
|
|
688
|
+
}
|
|
689
|
+
// + lists the hidden columns: a click adds one at the end.
|
|
690
|
+
if (!column) {
|
|
691
|
+
const shown = new Set(ids);
|
|
692
|
+
const hidden = [
|
|
693
|
+
...builtInColumns().filter((id) => !shown.has(id)).map((id) => ({ id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id] })),
|
|
694
|
+
...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
|
|
695
|
+
];
|
|
696
|
+
parts.push(...hidden.map((c) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ columns: [...ids, c.id] }); } }, c.label)));
|
|
697
|
+
if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
|
|
698
|
+
}
|
|
699
|
+
columnPop.replaceChildren(...parts);
|
|
700
|
+
columnPop.showPopover?.();
|
|
701
|
+
const x = event.clientX || event.currentTarget?.getBoundingClientRect?.().left || 0;
|
|
702
|
+
const y = event.clientY || event.currentTarget?.getBoundingClientRect?.().bottom || 0;
|
|
703
|
+
columnPop.style.top = `${Math.round(Math.min(y + 4, innerHeight - columnPop.offsetHeight - 8))}px`;
|
|
704
|
+
columnPop.style.left = `${Math.round(Math.max(8, Math.min(x, innerWidth - columnPop.offsetWidth - 8)))}px`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// The list's columns, in the order the person chose them. One column holds
|
|
708
|
+
// one field; each date has its own column.
|
|
709
|
+
function listColumns() {
|
|
710
|
+
const day = (iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
|
|
711
|
+
const late = (job) => (isLate(job) ? h('span', { class: 'fx-job__date--late', title: 'Past due' }, day(job.dueOn)) : day(job.dueOn));
|
|
712
|
+
const BUILT_IN = {
|
|
713
|
+
case: { kind: 'case', dim: true, cell: (job) => caseNumber(job) ?? '—' },
|
|
714
|
+
practitioner: { kind: 'text', dim: true, cell: (job) => job.practitioner ?? '—' },
|
|
715
|
+
patient: { kind: 'text', dim: true, cell: (job) => job.patient ?? '—' },
|
|
716
|
+
stage: { kind: 'stage', cell: (job) => stagePill(statusOf(job)) },
|
|
717
|
+
assignee: { kind: 'person', cell: (job) => personBadge(job, assigneeField()) },
|
|
718
|
+
ordered: { kind: 'date', dim: true, cell: (job) => day(job.orderedOn) },
|
|
719
|
+
due: { kind: 'date', dim: true, cell: late },
|
|
720
|
+
shipped: { kind: 'date', dim: true, cell: (job) => day(job.shippedOn) },
|
|
721
|
+
total: { kind: 'total', num: true, strong: true, cell: (job) => (job.totalCents == null ? '—' : formatCents(job.totalCents)) },
|
|
722
|
+
};
|
|
723
|
+
const allowed = new Set(builtInColumns());
|
|
724
|
+
return state.view.columns.map((id) => {
|
|
725
|
+
if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
|
|
726
|
+
const field = customColumns().find((f) => fieldGroup(f.id) === id);
|
|
727
|
+
if (!field) return null;
|
|
728
|
+
const cell = field.type === 'person' ? (job) => personBadge(job, field)
|
|
729
|
+
: field.type === 'date' ? (job) => day(valueOf(job, field.id)?.value ?? null) : (job) => fieldText(job, field);
|
|
730
|
+
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 };
|
|
731
|
+
}).filter(Boolean);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// A person custom field's value as a badge (initials), the name on hover.
|
|
735
|
+
function personBadge(job, field) {
|
|
736
|
+
const value = field ? valueOf(job, field.id)?.value ?? null : null;
|
|
737
|
+
if (!value) return h('span', { class: 'fx-dim' }, '—');
|
|
738
|
+
const name = state.people.find((p) => p.id === value)?.name ?? 'Former person';
|
|
739
|
+
return h('span', { class: 'fx-avatar fx-avatar--sm', role: 'img', 'aria-label': name, title: name }, initialsOf(name));
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// A job's custom field value as text.
|
|
743
|
+
function fieldText(job, field) {
|
|
744
|
+
const value = field ? valueOf(job, field.id)?.value ?? null : null;
|
|
745
|
+
if (value == null || value === false) return '—';
|
|
746
|
+
if (field.type === 'checkbox') return 'Yes';
|
|
747
|
+
if (field.type === 'person') return state.people.find((p) => p.id === value)?.name ?? 'Former person';
|
|
748
|
+
if (field.type === 'dropdown') return field.options?.find((o) => o.id === value)?.name ?? '—';
|
|
749
|
+
if (field.type === 'date') return showDate(value);
|
|
750
|
+
return String(value);
|
|
751
|
+
}
|
|
752
|
+
|
|
572
753
|
// The list: one block per group, with the group's label over its rows.
|
|
754
|
+
// A tick column at the left; a group's head has a tick for every job in
|
|
755
|
+
// the group, and a click on the head folds the group or opens it. Grouped
|
|
756
|
+
// by None, it is one plain list with no heads.
|
|
573
757
|
function list(groups, count) {
|
|
574
758
|
if (!count) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
|
|
759
|
+
const shownGroups = groups.filter((g) => g.jobs.length);
|
|
760
|
+
const folded = new Set(state.folded[state.view.group] ?? []);
|
|
761
|
+
const allJobs = shownGroups.flatMap((g) => g.jobs);
|
|
762
|
+
const boxes = [];
|
|
763
|
+
// A tick box for a set of jobs: ticked when all are, half when some are.
|
|
764
|
+
const setBox = (jobs, label) => {
|
|
765
|
+
const box = h('input', { type: 'checkbox', class: 'fx-tick', 'aria-label': label, onclick: (event) => event.stopPropagation() });
|
|
766
|
+
const sync = () => {
|
|
767
|
+
const on = jobs.filter((job) => state.ticked.has(jobKey(job))).length;
|
|
768
|
+
box.checked = on > 0 && on === jobs.length;
|
|
769
|
+
box.indeterminate = on > 0 && on < jobs.length;
|
|
770
|
+
};
|
|
771
|
+
box.addEventListener('change', () => {
|
|
772
|
+
for (const job of jobs) { if (box.checked) state.ticked.add(jobKey(job)); else state.ticked.delete(jobKey(job)); }
|
|
773
|
+
syncAll();
|
|
774
|
+
});
|
|
775
|
+
boxes.push(sync);
|
|
776
|
+
sync();
|
|
777
|
+
return box;
|
|
778
|
+
};
|
|
779
|
+
const syncAll = () => { for (const sync of boxes) sync(); drawBulk(); };
|
|
780
|
+
const tickCell = (box) => h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, box);
|
|
781
|
+
const columns = listColumns();
|
|
575
782
|
const row = (job) => h('tr', {
|
|
576
783
|
class: 'fx-row-link', tabindex: '0',
|
|
577
784
|
onclick: () => openJob(job),
|
|
578
|
-
onkeydown: (event) => { if (event.key === 'Enter') openJob(job); },
|
|
785
|
+
onkeydown: (event) => { if (event.key === 'Enter' && event.target === event.currentTarget) openJob(job); },
|
|
579
786
|
},
|
|
580
|
-
|
|
581
|
-
h('td', { class: 'fx-
|
|
582
|
-
h('td', {}
|
|
583
|
-
h('td', { class: 'fx-
|
|
584
|
-
|
|
787
|
+
tickCell(setBox([job], `Tick ${clientOf(job)}${caseNumber(job) ? `, case ${caseNumber(job)}` : ''}`)),
|
|
788
|
+
h('td', { class: 'fx-c-client' }, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
|
|
789
|
+
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))),
|
|
790
|
+
h('td', { class: 'fx-c-end' }));
|
|
791
|
+
const fold = (label) => {
|
|
792
|
+
const next = new Set(state.folded[state.view.group] ?? []);
|
|
793
|
+
if (next.has(label)) next.delete(label); else next.add(label);
|
|
794
|
+
state.folded = { ...state.folded, [state.view.group]: [...next] };
|
|
795
|
+
saveFolded(state.folded);
|
|
796
|
+
draw();
|
|
797
|
+
};
|
|
798
|
+
const head = ({ label, jobs, rank, color }) => {
|
|
799
|
+
const open = !folded.has(label);
|
|
800
|
+
const toggle = h('button', {
|
|
801
|
+
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`,
|
|
802
|
+
onclick: (event) => { event.stopPropagation(); fold(label); },
|
|
803
|
+
}, svg(CHEVRON_ICON),
|
|
804
|
+
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));
|
|
805
|
+
return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: () => fold(label) },
|
|
806
|
+
tickCell(setBox(jobs, `Tick every job in ${label}`)),
|
|
807
|
+
h('td', { colspan: String(columns.length + 2) }, h('span', { class: 'fx-table__group-row' }, toggle, h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))))));
|
|
808
|
+
};
|
|
585
809
|
return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
|
|
586
810
|
h('thead', {}, h('tr', {},
|
|
587
|
-
h('th', {
|
|
588
|
-
h('th', {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
811
|
+
h('th', { class: 'fx-tick-cell' }, setBox(allJobs, 'Tick every job shown')),
|
|
812
|
+
h('th', {}, 'Client'),
|
|
813
|
+
columns.map((c) => h('th', { class: c.num ? 'fx-num' : null, oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => columnMenu(c, event)); } },
|
|
814
|
+
h('button', { type: 'button', class: 'fx-th', 'aria-haspopup': 'menu', title: 'Column menu (or right-click)', onclick: (event) => columnMenu(c, event) }, c.label))),
|
|
815
|
+
h('th', { class: 'fx-th-add' }, h('button', {
|
|
816
|
+
type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => columnMenu(null, event),
|
|
817
|
+
}, '+')))),
|
|
818
|
+
shownGroups.map((g) => h('tbody', {},
|
|
819
|
+
g.label == null ? null : head(g),
|
|
820
|
+
g.label != null && folded.has(g.label) ? null : g.jobs.map(row)))));
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ── Bulk actions ─────────────────────────────────────────────────────────
|
|
824
|
+
// The bar floats at the foot while list rows are ticked (bulk-bar.js, as on
|
|
825
|
+
// Invoices). Move and Ready are the main steps; Copy and Export take the
|
|
826
|
+
// ticked rows out; More has them all, a field per custom field, and the
|
|
827
|
+
// Selection steps.
|
|
828
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
829
|
+
const tickedJobs = () => (state.found ?? []).filter((job) => state.ticked.has(jobKey(job)));
|
|
830
|
+
const setTicks = (jobs) => { state.ticked = new Set(jobs.map(jobKey)); draw(); };
|
|
831
|
+
|
|
832
|
+
function drawBulk() {
|
|
833
|
+
const rows = state.view.layout === 'list' ? tickedJobs() : [];
|
|
834
|
+
bulk.draw({ count: rows.length, onClear: () => setTicks([]), ...bulkActions(rows) });
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function bulkActions(rows) {
|
|
838
|
+
if (!rows.length) return { groups: [], more: [] };
|
|
839
|
+
const status = [{ icon: 'arrow-right-left', label: 'Move', title: 'Move to a stage', run: () => moveMany(rows) }];
|
|
840
|
+
if (state.fields.length) status.push({ icon: 'tag', label: 'Custom field', title: 'Set a custom field', run: () => setMany(rows) });
|
|
841
|
+
const workable = rows.filter((job) => job.obligation?.view === 'in_work');
|
|
842
|
+
if (workable.length) status.push({ icon: 'check-check', label: 'Ready', title: 'Mark ready to invoice', run: () => readyMany(workable) });
|
|
843
|
+
const draftable = state.canDraft ? rows.filter((job) => job.obligation?.view === 'ready' && !job.obligation.invoiceId) : [];
|
|
844
|
+
if (draftable.length) status.push({ icon: 'file-plus', label: 'Draft', title: 'Draft invoices', run: () => draftMany(draftable) });
|
|
845
|
+
const fields = state.fields.map((field) => ({ icon: 'tag', label: field.name, title: `Set ${field.name}`, run: () => setMany(rows, field.id) }));
|
|
846
|
+
const apply = [
|
|
847
|
+
{ icon: 'copy', label: 'Copy', iconOnly: true, title: 'Copy case numbers', run: () => copyLines(rows.map((job) => caseNumber(job) ?? clientOf(job))) },
|
|
848
|
+
{ icon: 'download', label: 'Export', iconOnly: true, title: 'Export CSV', run: () => exportCsv(rows) },
|
|
849
|
+
];
|
|
850
|
+
const all = state.found ?? [];
|
|
851
|
+
const selection = [
|
|
852
|
+
{ icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
|
|
853
|
+
{ icon: 'square-check', label: 'Invert ticks', run: () => setTicks(all.filter((job) => !state.ticked.has(jobKey(job)))) },
|
|
854
|
+
{ icon: 'square-x', label: 'Clear ticks', run: () => setTicks([]) },
|
|
855
|
+
];
|
|
856
|
+
return {
|
|
857
|
+
groups: [status, apply],
|
|
858
|
+
more: [
|
|
859
|
+
{ head: 'Change stage', actions: status },
|
|
860
|
+
{ head: 'Set a custom field', actions: fields },
|
|
861
|
+
{ head: 'Apply an action', actions: apply },
|
|
862
|
+
{ head: 'Selection', actions: selection },
|
|
863
|
+
],
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Move the ticked jobs to one stage. A work system's own stages
|
|
868
|
+
// (Seazona) cannot be moved from Flow in bulk yet, so this says so; a
|
|
869
|
+
// lab's own stages are custom fields (Field). Someone who needs it can
|
|
870
|
+
// report it (Report a problem).
|
|
871
|
+
async function moveMany(jobs) {
|
|
872
|
+
const systems = [...new Set(jobs.map((job) => SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem))].join(' and ');
|
|
873
|
+
await ui.dialog({
|
|
874
|
+
title: 'Not yet available',
|
|
875
|
+
body: h('p', {}, `Moving ${plural(jobs.length, 'job', 'jobs')} to a ${systems} stage at once is not yet available.`),
|
|
876
|
+
confirm: 'OK',
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// One step on each job's invoice record, one job after the other.
|
|
881
|
+
async function eachJob(jobs, step) {
|
|
882
|
+
let done = 0;
|
|
883
|
+
const refused = [];
|
|
884
|
+
for (const job of jobs) {
|
|
885
|
+
try { await step(job); done += 1; } catch (e) { refused.push(e); }
|
|
886
|
+
}
|
|
887
|
+
return { done, refused };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async function readyMany(jobs) {
|
|
891
|
+
const { done, refused } = await eachJob(jobs, (job) => flow(`/api/invoicing/jobs/${job.obligation.id}/ready`, { method: 'POST', body: JSON.stringify({ ready: true }) }));
|
|
892
|
+
ui.toast?.(refused.length ? `${done} ready to invoice. ${refused[0].message}` : `${plural(done, 'job', 'jobs')} ready to invoice.`, refused.length ? 'danger' : 'success');
|
|
893
|
+
state.ticked.clear();
|
|
894
|
+
await load();
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function draftMany(jobs) {
|
|
898
|
+
const ok = await ui.dialog({
|
|
899
|
+
title: `Draft ${plural(jobs.length, 'invoice', 'invoices')}?`,
|
|
900
|
+
body: h('p', {}, 'Flow makes a draft invoice for each job. The drafts stay in Flow until a person checks them.'),
|
|
901
|
+
confirm: `Draft ${jobs.length}`,
|
|
902
|
+
});
|
|
903
|
+
if (!ok) return;
|
|
904
|
+
const { done, refused } = await eachJob(jobs, (job) => flow(`/api/invoicing/jobs/${job.obligation.id}/draft`, { method: 'POST', body: '{}' }));
|
|
905
|
+
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');
|
|
906
|
+
state.ticked.clear();
|
|
907
|
+
await load();
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Set one custom field on the ticked jobs, in Flow only: choose the
|
|
911
|
+
// custom field, then its value. `fieldId` starts on that custom field.
|
|
912
|
+
// A dropdown, person or checkbox has a list of values; a date, number or
|
|
913
|
+
// text has a box. An empty box clears the value.
|
|
914
|
+
async function setMany(jobs, fieldId = null) {
|
|
915
|
+
const fields = state.fields;
|
|
916
|
+
if (!fields.length) return;
|
|
917
|
+
const lanesOf = (field) => fieldLanes(field, { people: state.people, jobs: state.jobs, valueOf: (job) => valueOf(job, field.id)?.value ?? null }).filter((lane) => !lane.archived);
|
|
918
|
+
const listed = (field) => field.groupable;
|
|
919
|
+
const pickField = h('select', { class: 'fx-select', name: 'field', 'aria-label': 'Custom field' },
|
|
920
|
+
fields.map((f) => h('option', { value: f.id, selected: f.id === fieldId }, f.name)));
|
|
921
|
+
const valueHost = h('div', {});
|
|
922
|
+
const fieldOf = (id) => fields.find((f) => f.id === id) ?? fields[0];
|
|
923
|
+
const fillValue = () => {
|
|
924
|
+
const field = fieldOf(pickField.value);
|
|
925
|
+
const control = listed(field)
|
|
926
|
+
? h('select', { class: 'fx-select', name: 'value', 'aria-label': 'Value' }, lanesOf(field).map((lane, i) => h('option', { value: String(i) }, lane.label)))
|
|
927
|
+
: h('input', {
|
|
928
|
+
class: 'fx-input', name: 'value', 'aria-label': 'Value',
|
|
929
|
+
type: field.type === 'date' ? 'date' : field.type === 'number' ? 'number' : 'text', step: field.type === 'number' ? 'any' : null, maxlength: field.type === 'text' ? '500' : null,
|
|
930
|
+
});
|
|
931
|
+
valueHost.replaceChildren(h('label', { class: 'fx-field' }, h('span', {}, 'Value'), control));
|
|
932
|
+
};
|
|
933
|
+
pickField.addEventListener('change', fillValue);
|
|
934
|
+
fillValue();
|
|
935
|
+
const data = await ui.dialog({
|
|
936
|
+
title: `Set a custom field on ${plural(jobs.length, 'job', 'jobs')}`,
|
|
937
|
+
body: h('div', { class: 'fx-fields' }, h('label', { class: 'fx-field' }, h('span', {}, 'Custom field'), pickField), valueHost),
|
|
938
|
+
confirm: 'Set',
|
|
939
|
+
});
|
|
940
|
+
if (!data) return;
|
|
941
|
+
const field = fields.find((f) => f.id === data.get('field'));
|
|
942
|
+
if (!field) return;
|
|
943
|
+
let value;
|
|
944
|
+
let shown;
|
|
945
|
+
if (listed(field)) {
|
|
946
|
+
const lane = lanesOf(field)[Number(data.get('value'))];
|
|
947
|
+
if (!lane) return;
|
|
948
|
+
({ value } = lane);
|
|
949
|
+
shown = lane.label;
|
|
950
|
+
} else {
|
|
951
|
+
const raw = String(data.get('value') ?? '').trim();
|
|
952
|
+
value = raw === '' ? null : field.type === 'number' ? Number(raw) : raw;
|
|
953
|
+
shown = raw === '' ? 'empty' : field.type === 'date' ? fullDate(raw) : raw;
|
|
954
|
+
}
|
|
955
|
+
for (const job of jobs) await setValue(job, field, value);
|
|
956
|
+
ui.toast?.(`${field.name} set to ${shown} on ${plural(jobs.length, 'job', 'jobs')}.`, 'success');
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
async function copyLines(lines) {
|
|
960
|
+
try {
|
|
961
|
+
await navigator.clipboard.writeText(lines.join('\n'));
|
|
962
|
+
ui.toast?.(`${plural(lines.length, 'line', 'lines')} copied.`, 'success');
|
|
963
|
+
} catch {
|
|
964
|
+
ui.toast?.('The browser did not allow a copy.', 'danger');
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// The ticked rows as a CSV file, with the columns on the screen.
|
|
969
|
+
function exportCsv(jobs) {
|
|
970
|
+
const cell = (v) => (/[",\n]/.test(String(v ?? '')) ? `"${String(v).replace(/"/g, '""')}"` : String(v ?? ''));
|
|
971
|
+
const lines = [['Case', 'Client', 'Practitioner', 'Stage', 'Due', 'Ordered', 'Total']]
|
|
972
|
+
.concat(jobs.map((job) => [caseNumber(job) ?? '', clientOf(job), job.practitioner ?? '', statusOf(job), job.dueOn ?? '', job.orderedOn ?? '',
|
|
973
|
+
job.totalCents == null ? '' : (job.totalCents / 100).toFixed(2)]));
|
|
974
|
+
const url = URL.createObjectURL(new Blob([lines.map((l) => l.map(cell).join(',')).join('\r\n')], { type: 'text/csv' }));
|
|
975
|
+
const a = h('a', { href: url, download: `jobs-${today()}.csv` });
|
|
976
|
+
document.body.append(a);
|
|
977
|
+
a.click();
|
|
978
|
+
a.remove();
|
|
979
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
980
|
+
ui.toast?.(`${plural(jobs.length, 'job', 'jobs')} exported.`, 'success');
|
|
593
981
|
}
|
|
594
982
|
|
|
595
983
|
// What a person can do with the job's invoice from here.
|
|
@@ -667,6 +1055,7 @@ export async function renderJobs(main, ui) {
|
|
|
667
1055
|
const o = job.obligation;
|
|
668
1056
|
const stage = invoiceStage(o);
|
|
669
1057
|
const found = problemsOf(job);
|
|
1058
|
+
const choose = found.find((f) => f.go === 'choose') ?? null;
|
|
670
1059
|
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
671
1060
|
const status = job.status ?? NO_STATUS;
|
|
672
1061
|
const late = isLate(job);
|
|
@@ -678,10 +1067,37 @@ export async function renderJobs(main, ui) {
|
|
|
678
1067
|
const invoiceText = o?.invoiceId
|
|
679
1068
|
? link({ href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } },
|
|
680
1069
|
`${o.invoiceStatus === 'draft' ? 'Draft' : 'Invoice'} ${o.invoiceNumber ?? ''}`.trim() + (stage ? ` · ${INVOICE_STAGES[stage]}` : ''))
|
|
681
|
-
:
|
|
682
|
-
|
|
1070
|
+
: choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
|
|
1071
|
+
: stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
|
|
1072
|
+
const rest = found.filter((f) => f.go !== 'choose');
|
|
1073
|
+
const fixBox = rest.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
|
|
683
1074
|
h('h3', {}, 'Fix'),
|
|
684
|
-
|
|
1075
|
+
rest.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, [f.text, f.todo].filter(Boolean).join(' ')), fixLink(f.go)))) : null;
|
|
1076
|
+
// No invoice, and the match is unclear: one line and two buttons. The
|
|
1077
|
+
// possible matches are compared on the Reconcile page, not here.
|
|
1078
|
+
const chooseBox = choose ? h('section', { class: 'fx-task__choose', 'aria-label': 'Invoice' },
|
|
1079
|
+
svg(WARN_ICON),
|
|
1080
|
+
h('span', {}, choose.text),
|
|
1081
|
+
h('div', { class: 'fx-task__choices' },
|
|
1082
|
+
state.canDraft ? h('button', { class: 'fx-btn fx-btn--primary', type: 'button', onclick: (event) => makeInvoice(event.currentTarget) }, 'Make invoice') : null,
|
|
1083
|
+
h('a', { class: 'fx-btn', href: `/reconcile/${o.id}`, onclick: (event) => { close(); ui.go(event); } }, 'Reconcile'))) : null;
|
|
1084
|
+
// "Make invoice": the person says the job has no invoice yet, then Flow
|
|
1085
|
+
// drafts it (the live check before the draft still runs).
|
|
1086
|
+
async function makeInvoice(button) {
|
|
1087
|
+
if (button) button.disabled = true;
|
|
1088
|
+
try {
|
|
1089
|
+
await flow(`/api/invoicing/jobs/${o.id}/no-invoice`, { method: 'POST', body: '{}' });
|
|
1090
|
+
const { invoice } = await flow(`/api/invoicing/jobs/${o.id}/draft`, { method: 'POST', body: '{}' });
|
|
1091
|
+
ui.toast('Draft invoice made.', 'success');
|
|
1092
|
+
close();
|
|
1093
|
+
ui.go(`/invoices/${invoice.id}`);
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
const reasons = error.details?.reasons;
|
|
1096
|
+
ui.toast(reasons?.length ? `Not drafted: ${reasons.join('; ')}.` : error.message, 'danger');
|
|
1097
|
+
close();
|
|
1098
|
+
await load();
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
685
1101
|
// Each fix's link: Flow's own invoice, the job in the work system, or
|
|
686
1102
|
// the Flow page where the fix is made (job-board.js GO).
|
|
687
1103
|
function fixLink(go) {
|
|
@@ -718,9 +1134,10 @@ export async function renderJobs(main, ui) {
|
|
|
718
1134
|
// The job links to Flow's invoice only. The invoice page shows its
|
|
719
1135
|
// link to an accounting system (its Linked card).
|
|
720
1136
|
field('Invoice', invoiceText)),
|
|
1137
|
+
chooseBox,
|
|
721
1138
|
fixBox,
|
|
722
1139
|
state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
|
|
723
|
-
h('h3', {}, '
|
|
1140
|
+
h('h3', {}, 'Custom fields'),
|
|
724
1141
|
h('dl', { class: 'fx-task__fields' }, state.fields.map((f) => h('div', {}, h('dt', {}, f.name), h('dd', {}, fieldControl(job, f)))))) : null,
|
|
725
1142
|
movesStages(job) ? h('section', { class: 'fx-task__custom', 'aria-label': `${system} stage` },
|
|
726
1143
|
h('h3', {}, `${system} stage`), stageControl(job)) : null,
|
|
@@ -745,7 +1162,7 @@ export async function renderJobs(main, ui) {
|
|
|
745
1162
|
element.addEventListener('close', () => element.remove());
|
|
746
1163
|
document.body.append(element);
|
|
747
1164
|
element.showModal();
|
|
748
|
-
if (atFix && fixBox) fixBox.scrollIntoView({ block: 'start' });
|
|
1165
|
+
if (atFix && (chooseBox ?? fixBox)) (chooseBox ?? fixBox).scrollIntoView({ block: 'start' });
|
|
749
1166
|
if (!o) return;
|
|
750
1167
|
const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
|
|
751
1168
|
flow(`/api/invoicing/jobs/${o.id}/activity`).then(({ events }) => {
|