@arcforgelabs/flow 2026.10.42 → 2026.10.44
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 +4 -3
- package/package.json +5 -3
- package/server/auth/people.js +40 -2
- package/server/auth/routes.js +11 -0
- package/server/auth/session.js +22 -3
- package/server/core/custom-fields.js +4 -3
- package/server/core/invoice-rules.js +8 -3
- package/server/core/job-match.js +6 -2
- package/server/core/sending.js +4 -3
- package/server/integrations/sentry/index.js +9 -1
- package/server/release-channel.js +5 -2
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +46 -162
- package/server/ui/automations.js +9 -3
- package/server/ui/columns.js +268 -0
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +268 -55
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +92 -35
- package/server/ui/invoicing.js +206 -119
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/job-view.js +255 -60
- package/server/ui/jobs.js +197 -145
- package/server/ui/kit.js +278 -0
- package/server/ui/list-views.js +73 -0
- package/server/ui/monitoring.js +1 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- package/server/ui/reconcile.js +114 -61
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +141 -0
- package/server/ui/search.js +8 -0
- package/server/ui/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/view-bar.js +176 -70
- package/server/ui/xero-import.js +7 -10
package/server/ui/jobs.js
CHANGED
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
// or with a keyboard, the job's panel has the same moves.
|
|
14
14
|
|
|
15
15
|
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
16
|
+
import { columnTable, onRightButton, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
16
17
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
17
18
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
19
|
+
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
18
20
|
import {
|
|
19
21
|
caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
|
|
20
22
|
} from '/assets/ui/job-board.js';
|
|
@@ -22,10 +24,16 @@ import {
|
|
|
22
24
|
COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
|
|
23
25
|
normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
|
|
24
26
|
} from '/assets/ui/job-view.js';
|
|
27
|
+
import { clipLabel, initialsOf } from '/assets/ui/person.js';
|
|
28
|
+
import { savedViews } from '/assets/ui/saved-views.js';
|
|
29
|
+
import { sidecar } from '/assets/ui/sidecar.js';
|
|
25
30
|
import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
|
|
26
31
|
|
|
27
32
|
const LAYOUT_KEY = 'flow_jobs_layout';
|
|
28
33
|
const FOLDED_KEY = 'flow_jobs_folded';
|
|
34
|
+
// The job panel's sidecar: the tab last open, or closed.
|
|
35
|
+
const SIDE_KEY = 'flow_job_side';
|
|
36
|
+
const SIDE_TABS = ['activity', 'fix', 'closed'];
|
|
29
37
|
const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
|
|
30
38
|
// A job's events (server/core/ledger.js), in plain words.
|
|
31
39
|
const ACTIVITY = {
|
|
@@ -56,11 +64,6 @@ const shortDate = (iso) => {
|
|
|
56
64
|
if (days === -1) return 'Yesterday';
|
|
57
65
|
return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
|
|
58
66
|
};
|
|
59
|
-
// A person's badge: their initials in a circle; the name on hover.
|
|
60
|
-
const initialsOf = (name) => {
|
|
61
|
-
const parts = (name ?? '?').split(/[\s._-]+/).filter(Boolean);
|
|
62
|
-
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
|
|
63
|
-
};
|
|
64
67
|
const clientOf = (job) => job.clientName ?? 'No client';
|
|
65
68
|
// The one date a card shows: when the job is due, else when it was ordered.
|
|
66
69
|
const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
|
|
@@ -85,6 +88,17 @@ function saveFolded(folded) {
|
|
|
85
88
|
try { localStorage.setItem(FOLDED_KEY, JSON.stringify(folded)); } catch { /* a convenience only */ }
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
function readSide() {
|
|
92
|
+
try {
|
|
93
|
+
const value = localStorage.getItem(SIDE_KEY);
|
|
94
|
+
return SIDE_TABS.includes(value) ? value : 'activity';
|
|
95
|
+
} catch { return 'activity'; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function saveSide(tab) {
|
|
99
|
+
try { localStorage.setItem(SIDE_KEY, tab ?? 'closed'); } catch { /* a convenience only */ }
|
|
100
|
+
}
|
|
101
|
+
|
|
88
102
|
|
|
89
103
|
const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6v3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r=".9" fill="currentColor"/></svg>';
|
|
90
104
|
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>';
|
|
@@ -92,7 +106,7 @@ const CHEVRON_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidde
|
|
|
92
106
|
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>';
|
|
93
107
|
|
|
94
108
|
export async function renderJobs(main, ui) {
|
|
95
|
-
const { h, flow,
|
|
109
|
+
const { h, flow, listHead, listFrame, edgeFade, notice } = ui;
|
|
96
110
|
// Search (search.js) opens a job with no invoice as /jobs?q=<case>.
|
|
97
111
|
const asked = new URLSearchParams(location.search).get('q') ?? '';
|
|
98
112
|
// The person's own view (job-view.js), saved with their account. Before
|
|
@@ -110,24 +124,26 @@ export async function renderJobs(main, ui) {
|
|
|
110
124
|
// The list's ticked jobs (by jobKey), and its folded groups by group key.
|
|
111
125
|
ticked: new Set(), folded: readFolded(),
|
|
112
126
|
};
|
|
127
|
+
// The person's named views (saved-views.js): their default opens on a
|
|
128
|
+
// new visit.
|
|
129
|
+
const views = savedViews(ui, { page: 'jobs', current: () => state.view, apply: (view) => { change(view); drawToggle(); } });
|
|
130
|
+
const opening = views.opening();
|
|
131
|
+
if (opening) state.view = normaliseJobsView(opening);
|
|
113
132
|
const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
|
|
114
133
|
const find = findBox(h, { label: 'Find a case, client or practitioner', value: asked, oninput: (words) => { state.q = words.trim(); draw(); } });
|
|
115
134
|
const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
|
|
116
135
|
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
|
|
117
136
|
// In the list, ticked rows get the same floating bar as Invoices.
|
|
118
137
|
const bulk = bulkBar(ui, { label: 'Ticked jobs' });
|
|
119
|
-
|
|
120
|
-
main.append(
|
|
121
|
-
pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
|
|
122
|
-
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, find.element, toggle)),
|
|
123
|
-
host,
|
|
124
|
-
bulk.element);
|
|
138
|
+
const frame = listFrame(main, host);
|
|
139
|
+
main.append(...listHead('Jobs', { views: views.element, start: controls, end: [find.element, toggle] }).nodes, frame.element, bulk.element);
|
|
125
140
|
|
|
126
141
|
const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
|
|
127
142
|
type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
|
|
128
143
|
onclick: () => { saveLayout(id); change({ layout: id }); drawToggle(); },
|
|
129
144
|
}, label)));
|
|
130
145
|
drawToggle();
|
|
146
|
+
views.draw();
|
|
131
147
|
|
|
132
148
|
const problemsOf = (job) => fixes(job.obligation);
|
|
133
149
|
const jobKey = (job) => `${job.sourceSystem}\u0000${job.sourceJobRef}`;
|
|
@@ -154,17 +170,13 @@ export async function renderJobs(main, ui) {
|
|
|
154
170
|
|
|
155
171
|
// A change to the view: draw it now, save it with the account shortly
|
|
156
172
|
// after, so a few quick clicks make one request.
|
|
157
|
-
|
|
173
|
+
const saveView = viewSaver(ui, 'jobs');
|
|
158
174
|
function change(patch) {
|
|
159
175
|
state.view = normaliseJobsView({ ...state.view, ...patch });
|
|
160
|
-
|
|
176
|
+
views.draw();
|
|
161
177
|
drawControls();
|
|
162
178
|
draw();
|
|
163
|
-
|
|
164
|
-
saveTimer = setTimeout(() => {
|
|
165
|
-
flow('/api/me/views/jobs', { method: 'PUT', body: JSON.stringify(state.view) })
|
|
166
|
-
.catch(() => ui.toast?.('Flow could not save your view. It applies on this page only.', 'danger'));
|
|
167
|
-
}, 500);
|
|
179
|
+
saveView(state.view);
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
// The chips (view-bar.js), shared with every list page.
|
|
@@ -226,11 +238,11 @@ export async function renderJobs(main, ui) {
|
|
|
226
238
|
}, { rows: true, clearLabel: 'Clear the sort' });
|
|
227
239
|
|
|
228
240
|
// ── Filters ──
|
|
229
|
-
//
|
|
230
|
-
// and
|
|
241
|
+
// The shared filter panel (view-bar.js), as ClickUp's: groups of rows of
|
|
242
|
+
// field, operator and value. A job's fields, then the lab's custom fields.
|
|
231
243
|
const filterFields = () => [
|
|
232
244
|
...Object.entries(FILTER_FIELDS).filter(([id]) => id !== 'assignee' || assigneeField())
|
|
233
|
-
.map(([value, f]) => ({ value, label: value === 'assignee' ? assigneeField().name : f.label })),
|
|
245
|
+
.map(([value, f]) => ({ value, label: value === 'assignee' ? assigneeField().name : f.label, icon: f.icon })),
|
|
234
246
|
{ group: 'Custom fields', options: customColumns().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
235
247
|
];
|
|
236
248
|
const kindOf = (field) => {
|
|
@@ -238,20 +250,23 @@ export async function renderJobs(main, ui) {
|
|
|
238
250
|
if (Object.hasOwn(FILTER_FIELDS, field)) return FILTER_FIELDS[field].kind;
|
|
239
251
|
return KIND_OF_TYPE[fieldById(fieldIdOf(field))?.type] ?? null;
|
|
240
252
|
};
|
|
241
|
-
|
|
242
|
-
|
|
253
|
+
// Status and stage are always set, so they offer Is and Is not, as
|
|
254
|
+
// ClickUp's Status does.
|
|
255
|
+
const PROBLEM_OPS = { yes: 'Has one', no: 'Has none' };
|
|
256
|
+
const SET_OPS = { is: 'Is', not: 'Is not' };
|
|
257
|
+
const opsOf = (field) => (field === 'problem' ? PROBLEM_OPS : ['status', 'stage'].includes(field) ? SET_OPS : OPERATORS[kindOf(field)] ?? {});
|
|
243
258
|
// The choices a choice or person filter offers: [{ value, label }].
|
|
244
259
|
function choicesOf(field) {
|
|
245
260
|
const people = () => [{ value: 'me', label: 'Me' }, ...state.people.map((p) => ({ value: p.id, label: p.name }))];
|
|
246
261
|
const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
|
|
247
|
-
if (field === 'status') return orderStatuses(state.statuses, state.jobs).map((v) => ({ value: v, label: v }));
|
|
262
|
+
if (field === 'status') return orderStatuses(state.statuses, state.jobs).map((v) => ({ value: v, label: v, rank: statusRank(v) }));
|
|
248
263
|
if (field === 'stage') return Object.values(STAGE_GROUPS).map((v) => ({ value: v, label: v }));
|
|
249
264
|
if (field === 'client') return labels(state.jobs.map(clientOf));
|
|
250
265
|
if (field === 'practitioner') return labels(state.jobs.map((job) => job.practitioner));
|
|
251
266
|
if (field === 'assignee') return people();
|
|
252
267
|
const custom = fieldById(fieldIdOf(field));
|
|
253
268
|
if (custom?.type === 'person') return people();
|
|
254
|
-
return (custom?.options ?? []).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name }));
|
|
269
|
+
return (custom?.options ?? []).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name, color: o.color }));
|
|
255
270
|
}
|
|
256
271
|
// A job's value for a filter field, as the filter compares it.
|
|
257
272
|
function filterValue(job, field) {
|
|
@@ -335,6 +350,7 @@ export async function renderJobs(main, ui) {
|
|
|
335
350
|
const shown = new Set(found.map(jobKey));
|
|
336
351
|
for (const key of state.ticked) if (!shown.has(key)) state.ticked.delete(key);
|
|
337
352
|
state.found = found;
|
|
353
|
+
frame.count(`${found.length} ${found.length === 1 ? 'job' : 'jobs'}`);
|
|
338
354
|
host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
|
|
339
355
|
drawBulk();
|
|
340
356
|
}
|
|
@@ -453,6 +469,9 @@ export async function renderJobs(main, ui) {
|
|
|
453
469
|
h('span', { class: 'fx-board__sum' }, jobs.length ? formatCents(totalOf(jobs)) : '')),
|
|
454
470
|
h('div', { class: 'fx-board__cards' }, jobs.length ? jobs.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')))));
|
|
455
471
|
if (groups.some((g) => g.drop)) enableDrag(element, groups);
|
|
472
|
+
// Lanes past the frame's sides, and cards past a lane's foot, fade.
|
|
473
|
+
edgeFade(element, { target: frame.view, edges: ['left', 'right'] });
|
|
474
|
+
element.querySelectorAll('.fx-board__cards').forEach((cards) => edgeFade(cards, { target: cards.parentElement, edges: ['top', 'bottom'] }));
|
|
456
475
|
return element;
|
|
457
476
|
}
|
|
458
477
|
|
|
@@ -649,10 +668,10 @@ export async function renderJobs(main, ui) {
|
|
|
649
668
|
const label = field.name;
|
|
650
669
|
if (field.type === 'dropdown' || field.type === 'person') {
|
|
651
670
|
const choices = field.type === 'person'
|
|
652
|
-
? state.people.map((p) => ({ value: p.id, label: p.name }))
|
|
671
|
+
? state.people.map((p) => ({ value: p.id, label: p.name, title: p.name }))
|
|
653
672
|
: field.options.filter((o) => !o.archived || o.id === current).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name }));
|
|
654
673
|
const select = h('select', { class: 'fx-select fx-task__input', 'aria-label': label },
|
|
655
|
-
h('option', { value: '' }, NOT_SET), choices.map((c) => h('option', { value: c.value, selected: c.value === current }, c.label)));
|
|
674
|
+
h('option', { value: '' }, NOT_SET), choices.map((c) => h('option', { value: c.value, selected: c.value === current, title: c.title ?? null }, clipLabel(c.label))));
|
|
656
675
|
select.addEventListener('change', () => save(select.value || null));
|
|
657
676
|
return select;
|
|
658
677
|
}
|
|
@@ -690,9 +709,6 @@ export async function renderJobs(main, ui) {
|
|
|
690
709
|
return h('div', { class: 'fx-task__move' }, wait, select);
|
|
691
710
|
}
|
|
692
711
|
|
|
693
|
-
// A column's menu (a click on its head, or a right-click): sort by it,
|
|
694
|
-
// move it, hide it. Client always shows. `column` null is the + menu: the
|
|
695
|
-
// hidden columns, to add one back.
|
|
696
712
|
// What a column's head sorts by: a built-in key, or the column's custom
|
|
697
713
|
// field (Assignee is the lab's person field).
|
|
698
714
|
const SORT_OF = { client: 'client', practitioner: 'practitioner', due: 'due', ordered: 'ordered', shipped: 'shipped', total: 'total' };
|
|
@@ -742,65 +758,43 @@ export async function renderJobs(main, ui) {
|
|
|
742
758
|
on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
|
|
743
759
|
}
|
|
744
760
|
function sortMenuAt(event, by, label, on, at) {
|
|
745
|
-
const close = () => columnPop.hidePopover?.();
|
|
746
761
|
const { sorts } = state.view;
|
|
747
762
|
const flip = () => change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)) });
|
|
748
|
-
|
|
749
|
-
h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { close(); flip(); } },
|
|
763
|
+
heads.menu.show([
|
|
764
|
+
h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { heads.menu.close(); flip(); } },
|
|
750
765
|
h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${label}`), h('span', { class: 'fx-pop__end' }, svg(ICONS.tick))),
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
placeColumnPop(event);
|
|
754
|
-
}
|
|
755
|
-
const columnPop = h('div', { class: 'fx-pop', id: 'fx-jobs-colmenu', popover: 'auto', role: 'menu' });
|
|
756
|
-
document.body.append(columnPop);
|
|
757
|
-
// A popover opened while the right button is still down closes again on
|
|
758
|
-
// its release (light dismiss), so open it after the release.
|
|
759
|
-
function onRightButton(event, open) {
|
|
760
|
-
if (event.buttons & 2) addEventListener('pointerup', () => setTimeout(open), { once: true });
|
|
761
|
-
else open();
|
|
766
|
+
heads.menu.item('ban', 'Clear sort', () => change({ sorts: sorts.filter((_, n) => n !== at) })),
|
|
767
|
+
], event);
|
|
762
768
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
769
|
+
// The list's heads (columns.js): drag an edge to resize, drag a head to
|
|
770
|
+
// move it; a click or right-click opens its menu: sort by it, move it,
|
|
771
|
+
// edit its custom field, hide it. Client always shows, first. + lists the
|
|
772
|
+
// hidden columns, to add one back.
|
|
773
|
+
const heads = columnTable(h, {
|
|
774
|
+
order: () => state.view.columns,
|
|
775
|
+
widths: () => state.view.widths,
|
|
776
|
+
change,
|
|
777
|
+
hidden: () => {
|
|
778
|
+
const shown = new Set(state.view.columns);
|
|
779
|
+
return [
|
|
780
|
+
...builtInColumns().filter((id) => !shown.has(id)).map((id) => ({ id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id] })),
|
|
781
|
+
...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
|
|
782
|
+
];
|
|
783
|
+
},
|
|
784
|
+
extra: (column, item) => {
|
|
785
|
+
const parts = [];
|
|
770
786
|
const sort = sortOf(column.id);
|
|
771
787
|
if (sort) {
|
|
772
788
|
const [up, down] = dirWords(sort);
|
|
773
789
|
parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sorts: [{ by: sort, dir: 'asc' }] })),
|
|
774
790
|
item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sorts: [{ by: sort, dir: 'desc' }] })), h('div', { class: 'fx-pop__sep' }));
|
|
775
791
|
}
|
|
776
|
-
const rest = ids.filter((c) => c !== column.id);
|
|
777
|
-
parts.push(item('arrow-left-to-line', 'Move to start', () => change({ columns: [column.id, ...rest] })),
|
|
778
|
-
item('arrow-right-to-line', 'Move to end', () => change({ columns: [...rest, column.id] })));
|
|
779
792
|
if (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)) {
|
|
780
793
|
parts.push(item('settings', 'Edit custom field', () => ui.go('/custom-fields')));
|
|
781
794
|
}
|
|
782
|
-
parts
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
if (!column) {
|
|
786
|
-
const shown = new Set(ids);
|
|
787
|
-
const hidden = [
|
|
788
|
-
...builtInColumns().filter((id) => !shown.has(id)).map((id) => ({ id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id] })),
|
|
789
|
-
...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
|
|
790
|
-
];
|
|
791
|
-
parts.push(...hidden.map((c) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ columns: [...ids, c.id] }); } }, c.label)));
|
|
792
|
-
if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
|
|
793
|
-
}
|
|
794
|
-
columnPop.replaceChildren(...parts);
|
|
795
|
-
placeColumnPop(event);
|
|
796
|
-
}
|
|
797
|
-
function placeColumnPop(event) {
|
|
798
|
-
columnPop.showPopover?.();
|
|
799
|
-
const x = event.clientX || event.currentTarget?.getBoundingClientRect?.().left || 0;
|
|
800
|
-
const y = event.clientY || event.currentTarget?.getBoundingClientRect?.().bottom || 0;
|
|
801
|
-
columnPop.style.top = `${Math.round(Math.min(y + 4, innerHeight - columnPop.offsetHeight - 8))}px`;
|
|
802
|
-
columnPop.style.left = `${Math.round(Math.max(8, Math.min(x, innerWidth - columnPop.offsetWidth - 8)))}px`;
|
|
803
|
-
}
|
|
795
|
+
return parts;
|
|
796
|
+
},
|
|
797
|
+
});
|
|
804
798
|
|
|
805
799
|
// The list's columns, in the order the person chose them. One column holds
|
|
806
800
|
// one field; each date has its own column.
|
|
@@ -808,25 +802,30 @@ export async function renderJobs(main, ui) {
|
|
|
808
802
|
const day = (iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
|
|
809
803
|
const late = (job) => (isLate(job) ? h('span', { class: 'fx-job__date--late', title: 'Past due' }, day(job.dueOn)) : day(job.dueOn));
|
|
810
804
|
const BUILT_IN = {
|
|
811
|
-
case: { kind: 'case',
|
|
812
|
-
practitioner: { kind: 'text',
|
|
813
|
-
patient: { kind: 'text',
|
|
805
|
+
case: { kind: 'case', className: 'fx-dim', cell: (job) => caseNumber(job) ?? '—' },
|
|
806
|
+
practitioner: { kind: 'text', className: 'fx-dim', cell: (job) => job.practitioner ?? '—' },
|
|
807
|
+
patient: { kind: 'text', className: 'fx-dim', cell: (job) => job.patient ?? '—' },
|
|
814
808
|
stage: { kind: 'stage', cell: (job) => stagePill(statusOf(job)) },
|
|
815
809
|
assignee: { kind: 'person', cell: (job) => personBadge(job, assigneeField()) },
|
|
816
|
-
ordered: { kind: 'date',
|
|
817
|
-
due: { kind: 'date',
|
|
818
|
-
shipped: { kind: 'date',
|
|
819
|
-
total: { kind: 'total', num: true,
|
|
810
|
+
ordered: { kind: 'date', className: 'fx-dim', cell: (job) => day(job.orderedOn) },
|
|
811
|
+
due: { kind: 'date', className: 'fx-dim', cell: late },
|
|
812
|
+
shipped: { kind: 'date', className: 'fx-dim', cell: (job) => day(job.shippedOn) },
|
|
813
|
+
total: { kind: 'total', num: true, className: 'fx-strong', cell: (job) => (job.totalCents == null ? '—' : formatCents(job.totalCents)) },
|
|
820
814
|
};
|
|
821
815
|
const allowed = new Set(builtInColumns());
|
|
822
|
-
|
|
816
|
+
// Client is always the first column: it has no drag, Move or Hide.
|
|
817
|
+
const client = {
|
|
818
|
+
id: 'client', label: 'Client', kind: 'client', fixed: true, sort: () => sortButton('client', 'Client'),
|
|
819
|
+
cell: (job) => h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job)),
|
|
820
|
+
};
|
|
821
|
+
return [client, ...state.view.columns.map((id) => {
|
|
823
822
|
if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
|
|
824
823
|
const field = customColumns().find((f) => fieldGroup(f.id) === id);
|
|
825
824
|
if (!field) return null;
|
|
826
825
|
const cell = field.type === 'person' ? (job) => personBadge(job, field)
|
|
827
826
|
: field.type === 'date' ? (job) => day(valueOf(job, field.id)?.value ?? null) : (job) => fieldText(job, field);
|
|
828
|
-
return { id, label: field.name, kind: field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text', num: field.type === 'number',
|
|
829
|
-
}).filter(Boolean);
|
|
827
|
+
return { id, label: field.name, kind: field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text', num: field.type === 'number', className: 'fx-dim', cell };
|
|
828
|
+
}).filter(Boolean).map((c) => ({ ...c, sort: () => sortButton(c.id, c.label) }))];
|
|
830
829
|
}
|
|
831
830
|
|
|
832
831
|
// A person custom field's value as a badge (initials), the name on hover.
|
|
@@ -883,8 +882,7 @@ export async function renderJobs(main, ui) {
|
|
|
883
882
|
onkeydown: (event) => { if (event.key === 'Enter' && event.target === event.currentTarget) openJob(job); },
|
|
884
883
|
},
|
|
885
884
|
tickCell(setBox([job], `Tick ${clientOf(job)}${caseNumber(job) ? `, case ${caseNumber(job)}` : ''}`)),
|
|
886
|
-
|
|
887
|
-
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))),
|
|
885
|
+
rowCells(h, columns, job),
|
|
888
886
|
h('td', { class: 'fx-c-end' }));
|
|
889
887
|
const fold = (label) => {
|
|
890
888
|
const next = new Set(state.folded[state.view.group] ?? []);
|
|
@@ -902,22 +900,16 @@ export async function renderJobs(main, ui) {
|
|
|
902
900
|
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));
|
|
903
901
|
return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: () => fold(label) },
|
|
904
902
|
tickCell(setBox(jobs, `Tick every job in ${label}`)),
|
|
905
|
-
h('td', { colspan: String(columns.length +
|
|
903
|
+
h('td', { colspan: String(columns.length + 1) }, h('span', { class: 'fx-table__group-row' }, toggle, h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))))));
|
|
906
904
|
};
|
|
907
|
-
return h('div', { class: 'fx-panel' },
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
h('span', { class: 'fx-th-cell' },
|
|
913
|
-
h('button', { type: 'button', class: 'fx-th', 'aria-haspopup': 'menu', title: 'Column menu (or right-click)', onclick: (event) => columnMenu(c, event) }, c.label),
|
|
914
|
-
sortButton(c.id, c.label)))),
|
|
915
|
-
h('th', { class: 'fx-th-add' }, h('button', {
|
|
916
|
-
type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => columnMenu(null, event),
|
|
917
|
-
}, '+')))),
|
|
918
|
-
shownGroups.map((g) => h('tbody', {},
|
|
905
|
+
return h('div', { class: 'fx-panel' }, heads.table({
|
|
906
|
+
className: 'fx-table--jobs',
|
|
907
|
+
columns,
|
|
908
|
+
lead: [h('th', { class: 'fx-tick-cell' }, setBox(allJobs, 'Tick every job shown'))],
|
|
909
|
+
rows: shownGroups.map((g) => h('tbody', {},
|
|
919
910
|
g.label == null ? null : head(g),
|
|
920
|
-
g.label != null && folded.has(g.label) ? null : g.jobs.map(row)))
|
|
911
|
+
g.label != null && folded.has(g.label) ? null : g.jobs.map(row))),
|
|
912
|
+
}));
|
|
921
913
|
}
|
|
922
914
|
|
|
923
915
|
// ── Bulk actions ─────────────────────────────────────────────────────────
|
|
@@ -1086,25 +1078,8 @@ export async function renderJobs(main, ui) {
|
|
|
1086
1078
|
if (!o) return [];
|
|
1087
1079
|
if (o.invoiceId) {
|
|
1088
1080
|
const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
// puts the job's new lines on Flow's own draft. Nothing goes elsewhere.
|
|
1092
|
-
if (!o.canUseNewLines) return [open];
|
|
1093
|
-
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
1094
|
-
return [h('button', {
|
|
1095
|
-
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
1096
|
-
onclick: async (event) => {
|
|
1097
|
-
event.currentTarget.disabled = true;
|
|
1098
|
-
try {
|
|
1099
|
-
await flow(`/api/invoicing/jobs/${o.id}/use-new-lines`, { method: 'POST', body: '{}' });
|
|
1100
|
-
ui.toast(`The draft invoice now matches ${system}.`, 'success');
|
|
1101
|
-
} catch (error) {
|
|
1102
|
-
ui.toast(error.message, 'danger');
|
|
1103
|
-
}
|
|
1104
|
-
close();
|
|
1105
|
-
await load();
|
|
1106
|
-
},
|
|
1107
|
-
}, `Adopt ${system} changes`), open];
|
|
1081
|
+
// A changed job is accepted in the panel's sidecar (Fix tab).
|
|
1082
|
+
return [h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label)];
|
|
1108
1083
|
}
|
|
1109
1084
|
// Flow's own stage: a person marks a job ready to invoice, or an
|
|
1110
1085
|
// automation does (Settings, Automations).
|
|
@@ -1147,15 +1122,20 @@ export async function renderJobs(main, ui) {
|
|
|
1147
1122
|
|
|
1148
1123
|
// The job's page: a large panel over the board, like a task page. A
|
|
1149
1124
|
// breadcrumb, the client's name, the stage, the job's fields, what to fix
|
|
1150
|
-
// and its lines
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1125
|
+
// and its lines. At the side (below on a phone), the sidecar: Activity,
|
|
1126
|
+
// and Fix when the job has a problem (for a changed job: what changed,
|
|
1127
|
+
// and Accept change). The sidecar closes, and the main area takes its room.
|
|
1128
|
+
// Esc, the X or a click outside closes the panel. `atFix` opens the
|
|
1129
|
+
// Fix tab on the first problem (or scrolls to the invoice choice). The job's custom fields can
|
|
1130
|
+
// be changed here (in Flow only), and its stage moved (a hand-off to the
|
|
1131
|
+
// work system).
|
|
1154
1132
|
function openJob(job, { atFix = false } = {}) {
|
|
1155
1133
|
const o = job.obligation;
|
|
1156
1134
|
const stage = invoiceStage(o);
|
|
1157
1135
|
const found = problemsOf(job);
|
|
1158
1136
|
const choose = found.find((f) => f.go === 'choose') ?? null;
|
|
1137
|
+
// The job changed after its draft: its Fix shows the change.
|
|
1138
|
+
const reviewable = Boolean(o?.invoiceId) && found.some((f) => f.go === 'review');
|
|
1159
1139
|
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
1160
1140
|
const status = job.status ?? NO_STATUS;
|
|
1161
1141
|
const late = isLate(job);
|
|
@@ -1170,9 +1150,24 @@ export async function renderJobs(main, ui) {
|
|
|
1170
1150
|
: choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
|
|
1171
1151
|
: stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
|
|
1172
1152
|
const rest = found.filter((f) => f.go !== 'choose');
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1153
|
+
// What to fix: one chip a problem, beside the stage, so the panel keeps
|
|
1154
|
+
// one layout with or without a fix. A chip is a toggle: a click opens
|
|
1155
|
+
// the sidecar's Fix tab on that problem and shows the chip pressed; a
|
|
1156
|
+
// second click closes the sidecar. In the tab the problem is fixed: the
|
|
1157
|
+
// change and Accept change for a changed job, else what is wrong, what
|
|
1158
|
+
// to do and one button.
|
|
1159
|
+
const hasFix = rest.length > 0;
|
|
1160
|
+
let picked = null;
|
|
1161
|
+
const fixChips = rest.map((f, i) => h('button', {
|
|
1162
|
+
class: 'fx-task__fixchip', type: 'button', 'aria-pressed': 'false', onclick: () => toggleFix(i),
|
|
1163
|
+
}, lucide(h, 'triangle-alert'), f.label));
|
|
1164
|
+
function toggleFix(i) {
|
|
1165
|
+
if (picked === i && side.tab() === 'fix') { side.close(); return; }
|
|
1166
|
+
picked = i;
|
|
1167
|
+
side.open('fix');
|
|
1168
|
+
// On a phone the sidecar is below the job: bring it into view.
|
|
1169
|
+
if (matchMedia('(max-width: 720px)').matches) side.element.scrollIntoView({ block: 'start' });
|
|
1170
|
+
}
|
|
1176
1171
|
// No invoice, and the match is unclear: one line and two buttons. The
|
|
1177
1172
|
// possible matches are compared on the Reconcile page, not here.
|
|
1178
1173
|
const chooseBox = choose ? h('section', { class: 'fx-task__choose', 'aria-label': 'Invoice' },
|
|
@@ -1200,27 +1195,84 @@ export async function renderJobs(main, ui) {
|
|
|
1200
1195
|
}
|
|
1201
1196
|
// Each fix's link: Flow's own invoice, the job in the work system, or
|
|
1202
1197
|
// the Flow page where the fix is made (job-board.js GO).
|
|
1203
|
-
function fixLink(go) {
|
|
1204
|
-
const
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
}
|
|
1209
|
-
if (go === '
|
|
1210
|
-
if (go === '
|
|
1211
|
-
if (go === '
|
|
1198
|
+
function fixLink(go, tip = null) {
|
|
1199
|
+
const button = (icon, text, attrs) => h(attrs.href ? 'a' : 'button', {
|
|
1200
|
+
class: 'fx-btn fx-btn--sm fx-btn--primary fx-task__fixgo', title: tip, ...(attrs.href ? {} : { type: 'button' }), ...attrs,
|
|
1201
|
+
}, lucide(h, icon), text);
|
|
1202
|
+
const inFlow = (icon, href, text) => button(icon, text, { href, onclick: (event) => { close(); ui.go(event); } });
|
|
1203
|
+
if ((go === 'review' || go === 'invoice') && o?.invoiceId) return inFlow('file-text', `/invoices/${o.invoiceId}`, 'Invoice');
|
|
1204
|
+
if (go === 'work' && job.link?.url) return button('external-link', system, { href: job.link.url, target: '_blank', rel: 'noopener' });
|
|
1205
|
+
if (go === 'price-list') return inFlow('tag', '/price-list', 'Price list');
|
|
1206
|
+
if (go === 'invoicing-settings') return inFlow('list-checks', '/invoicing-settings', 'Rules');
|
|
1207
|
+
if (go === 'integrations' && ui.me?.person?.role === 'owner') return inFlow('plug', '/integrations', 'Integrations');
|
|
1212
1208
|
return null;
|
|
1213
1209
|
}
|
|
1214
1210
|
const lines = job.lines ?? [];
|
|
1215
1211
|
const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
|
|
1212
|
+
const activityBody = h('div', { class: 'fx-task__activity' },
|
|
1213
|
+
o ? activity : h('p', { class: 'fx-dim' }, `Flow has no invoice record for this job yet. It makes one when ${system} reports the job complete.`));
|
|
1214
|
+
// The change: as the invoice page shows it (job-change.js),
|
|
1215
|
+
// read from the invoice when the sidecar first needs it.
|
|
1216
|
+
const reviewBody = h('div', { class: 'fx-task__review' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
1217
|
+
let reviewRead = false;
|
|
1218
|
+
const readReview = () => {
|
|
1219
|
+
if (reviewRead) return;
|
|
1220
|
+
reviewRead = true;
|
|
1221
|
+
flow(`/api/invoicing/invoices/${o.invoiceId}`).then(({ invoice }) => drawReview(invoice))
|
|
1222
|
+
.catch(() => reviewBody.replaceChildren(h('p', {}, 'Flow could not read the change.')));
|
|
1223
|
+
};
|
|
1224
|
+
function drawReview(invoice) {
|
|
1225
|
+
const change = invoice?.jobChange ?? null;
|
|
1226
|
+
reviewBody.replaceChildren(change
|
|
1227
|
+
? jobChangeReview(ui, change, { money: formatCents, canAccept: state.canDraft, done: reopen })
|
|
1228
|
+
: h('p', { class: 'fx-dim' }, `The draft matches ${system}.`));
|
|
1229
|
+
}
|
|
1230
|
+
// After Accept change or a recheck: the board reads again, and this
|
|
1231
|
+
// job's panel opens again with its new facts.
|
|
1232
|
+
async function reopen() {
|
|
1233
|
+
await load();
|
|
1234
|
+
const fresh = state.jobs.find((j) => jobKey(j) === jobKey(job));
|
|
1235
|
+
close();
|
|
1236
|
+
if (fresh) openJob(fresh);
|
|
1237
|
+
}
|
|
1238
|
+
// The Fix tab: the picked problem (the first when the tab itself is
|
|
1239
|
+
// clicked).
|
|
1240
|
+
function fixBody() {
|
|
1241
|
+
const f = rest[picked ?? 0];
|
|
1242
|
+
if (f.go === 'review' && reviewable) {
|
|
1243
|
+
readReview();
|
|
1244
|
+
return h('section', { class: 'fx-task__fixitem', 'aria-label': f.label }, reviewBody);
|
|
1245
|
+
}
|
|
1246
|
+
return h('section', { class: 'fx-task__fixitem', 'aria-label': f.label },
|
|
1247
|
+
h('strong', {}, f.label), h('p', {}, f.text), f.todo ? h('p', { class: 'fx-dim' }, f.todo) : null, fixLink(f.go));
|
|
1248
|
+
}
|
|
1249
|
+
const sideButton = h('button', {
|
|
1250
|
+
class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Side panel', title: 'Side panel', 'aria-pressed': 'false',
|
|
1251
|
+
onclick: () => (side.isOpen() ? side.close() : side.open(readSide() === 'fix' && hasFix ? 'fix' : 'activity')),
|
|
1252
|
+
}, lucide(h, 'panel-right'));
|
|
1253
|
+
const side = sidecar(h, {
|
|
1254
|
+
label: 'Job side panel',
|
|
1255
|
+
tabs: [
|
|
1256
|
+
{ id: 'activity', label: 'Activity', icon: 'history', render: () => activityBody },
|
|
1257
|
+
hasFix ? { id: 'fix', label: 'Fix', icon: 'triangle-alert', mark: true, render: () => { picked ??= 0; return fixBody(); } } : null,
|
|
1258
|
+
].filter(Boolean),
|
|
1259
|
+
onChange: (tab) => {
|
|
1260
|
+
saveSide(tab);
|
|
1261
|
+
sideButton.setAttribute('aria-pressed', String(Boolean(tab)));
|
|
1262
|
+
if (tab !== 'fix') picked = null;
|
|
1263
|
+
fixChips.forEach((chip, i) => chip.setAttribute('aria-pressed', String(tab === 'fix' && picked === i)));
|
|
1264
|
+
},
|
|
1265
|
+
});
|
|
1266
|
+
side.element.classList.add('fx-task__side');
|
|
1216
1267
|
element.append(h('div', { class: 'fx-task__grid' },
|
|
1217
1268
|
h('div', { class: 'fx-task__main' },
|
|
1218
1269
|
h('header', { class: 'fx-task__head' },
|
|
1219
1270
|
h('nav', { class: 'fx-task__crumb', 'aria-label': 'Breadcrumb' },
|
|
1220
1271
|
h('span', {}, 'Jobs'), h('span', { class: 'fx-task__slash', 'aria-hidden': 'true' }, '/'), h('span', {}, status)),
|
|
1221
|
-
h('
|
|
1272
|
+
h('div', { class: 'fx-task__tools' }, sideButton,
|
|
1273
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: close }, '✕'))),
|
|
1222
1274
|
h('h2', { class: 'fx-task__title' }, clientOf(job)),
|
|
1223
|
-
h('div', { class: 'fx-task__stage' }, stagePill(status)),
|
|
1275
|
+
h('div', { class: 'fx-task__stage' }, stagePill(status), fixChips),
|
|
1224
1276
|
h('dl', { class: 'fx-task__fields' },
|
|
1225
1277
|
field('Practitioner', job.practitioner),
|
|
1226
1278
|
field(job.dueOn ? 'Due' : 'Ordered', job.dueOn ? fullDate(job.dueOn) : job.orderedOn ? fullDate(job.orderedOn) : null, late ? { class: 'fx-task__late' } : {}),
|
|
@@ -1235,7 +1287,6 @@ export async function renderJobs(main, ui) {
|
|
|
1235
1287
|
// link to an accounting system (its Linked card).
|
|
1236
1288
|
field('Invoice', invoiceText)),
|
|
1237
1289
|
chooseBox,
|
|
1238
|
-
fixBox,
|
|
1239
1290
|
state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
|
|
1240
1291
|
h('h3', {}, 'Custom fields'),
|
|
1241
1292
|
h('dl', { class: 'fx-task__fields' }, state.fields.map((f) => h('div', {}, h('dt', {}, f.name), h('dd', {}, fieldControl(job, f)))))) : null,
|
|
@@ -1255,14 +1306,15 @@ export async function renderJobs(main, ui) {
|
|
|
1255
1306
|
h('td', { class: 'fx-num' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))))
|
|
1256
1307
|
: h('p', { class: 'fx-dim' }, 'No lines.')),
|
|
1257
1308
|
h('div', { class: 'fx-task__actions' }, actions(job, close))),
|
|
1258
|
-
|
|
1259
|
-
h('h3', {}, 'Activity'),
|
|
1260
|
-
o ? activity : h('p', { class: 'fx-dim' }, `Flow has no invoice record for this job yet. It makes one when ${system} reports the job complete.`))));
|
|
1309
|
+
side.element));
|
|
1261
1310
|
element.addEventListener('click', (event) => { if (event.target === element) close(); });
|
|
1262
1311
|
element.addEventListener('close', () => element.remove());
|
|
1263
1312
|
document.body.append(element);
|
|
1264
1313
|
element.showModal();
|
|
1265
|
-
|
|
1314
|
+
const last = readSide();
|
|
1315
|
+
if (atFix && hasFix) toggleFix(0);
|
|
1316
|
+
else if (last !== 'closed') side.open(last === 'fix' && hasFix ? 'fix' : 'activity');
|
|
1317
|
+
if (atFix && chooseBox) chooseBox.scrollIntoView({ block: 'start' });
|
|
1266
1318
|
if (!o) return;
|
|
1267
1319
|
const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
|
|
1268
1320
|
flow(`/api/invoicing/jobs/${o.id}/activity`).then(({ events }) => {
|