@arcforgelabs/flow 2026.10.33 → 2026.10.35
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 +7 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/custom-fields.js +115 -0
- package/server/api/invoicing.js +43 -12
- package/server/api/work-moves.js +80 -0
- package/server/app.js +12 -0
- package/server/auth/people.js +35 -0
- package/server/auth/routes.js +14 -0
- package/server/core/custom-fields.js +308 -0
- package/server/core/job-board.js +4 -0
- package/server/core/ledger.js +82 -2
- package/server/core/tracing.js +64 -0
- package/server/db/migrations/0018_custom_fields.sql +78 -0
- package/server/db/migrations/0019_people_views.sql +1 -0
- package/server/db/migrations/meta/0018_snapshot.json +4289 -0
- package/server/db/migrations/meta/0019_snapshot.json +4295 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +64 -1
- package/server/index.js +6 -1
- package/server/integrations/port.js +8 -1
- package/server/integrations/seazona/index.js +60 -2
- package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
- package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
- package/server/integrations/seazona/schema.js +26 -0
- package/server/integrations/seazona/settings.js +35 -0
- package/server/integrations/seazona/stage-moves.js +133 -0
- package/server/integrations/seazona/tables.js +2 -0
- package/server/integrations/sentry/index.js +61 -10
- package/server/release-channel.js +8 -6
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +40 -2
- package/server/ui/custom-fields.js +190 -0
- package/server/ui/flow.css +128 -10
- package/server/ui/invoice-notes.js +1 -1
- package/server/ui/invoicing.js +185 -48
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +78 -0
- package/server/ui/job-view.js +119 -0
- package/server/ui/jobs.js +482 -49
- package/server/ui/monitoring.js +44 -2
- package/server/ui/routes.js +1 -1
package/server/ui/jobs.js
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
// Jobs (/jobs): the work system's jobs, as a board with one column per work
|
|
2
|
-
// system status (its own statuses, one to one), or as a list.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// system status (its own statuses, one to one), or as a list. A click on a
|
|
3
|
+
// job opens its detail panel: the job, its lines, its invoice, its custom
|
|
4
|
+
// fields, and what to fix. Data comes from /api/invoicing/board
|
|
5
|
+
// (server/core/job-board.js); the rules are in server/ui/job-board.js and
|
|
6
|
+
// server/ui/job-view.js.
|
|
7
|
+
//
|
|
8
|
+
// A card can be dragged to another lane with a mouse or pen
|
|
9
|
+
// (docs/JOB_BOARD_MOVES.md). Grouped by a custom field, the move sets the
|
|
10
|
+
// job's value, in Flow only. Grouped by status, the move is a stage move
|
|
11
|
+
// in the work system: Seazona cannot take one through its API, so Flow
|
|
12
|
+
// shows what to do there and the card waits in its new lane. On a phone,
|
|
13
|
+
// or with a keyboard, the job's panel has the same moves.
|
|
7
14
|
|
|
8
15
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
9
16
|
import {
|
|
10
17
|
FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
|
|
11
18
|
} from '/assets/ui/job-board.js';
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, filterCount, filterJobs, groupJobs, GROUPS, laneOf, normaliseJobsView, NOT_SET, sortJobs, SORTS,
|
|
21
|
+
} from '/assets/ui/job-view.js';
|
|
12
22
|
|
|
13
23
|
const LAYOUT_KEY = 'flow_jobs_layout';
|
|
14
24
|
const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
|
|
@@ -23,6 +33,9 @@ const ACTIVITY = {
|
|
|
23
33
|
writeback_not_supported: 'The lab system does not take the status',
|
|
24
34
|
};
|
|
25
35
|
const BY = { person: 'A person', automation: 'An automation' };
|
|
36
|
+
// Grouped by invoice stage: a job with no invoice stage yet is still in work.
|
|
37
|
+
const STAGE_GROUPS = { in_work: 'In work', ...INVOICE_STAGES };
|
|
38
|
+
const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4, Invoiced: 5, Paid: 6 };
|
|
26
39
|
|
|
27
40
|
const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
|
|
28
41
|
const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
|
|
@@ -46,53 +59,190 @@ const today = () => {
|
|
|
46
59
|
|
|
47
60
|
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>';
|
|
48
61
|
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
|
+
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
|
+
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>';
|
|
64
|
+
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
|
+
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>';
|
|
49
66
|
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>';
|
|
50
67
|
|
|
51
68
|
export async function renderJobs(main, ui) {
|
|
52
69
|
const { h, flow, pageHeader, notice } = ui;
|
|
53
70
|
// Search (search.js) opens a job with no invoice as /jobs?q=<case>.
|
|
54
71
|
const asked = new URLSearchParams(location.search).get('q') ?? '';
|
|
55
|
-
|
|
72
|
+
// The person's own view (job-view.js), saved with their account. Before
|
|
73
|
+
// they first change it, the layout this browser last used still applies.
|
|
74
|
+
const saved = ui.me?.views?.jobs;
|
|
75
|
+
const state = {
|
|
76
|
+
view: normaliseJobsView(saved ?? { ...DEFAULT_VIEW, layout: readLayout() }),
|
|
77
|
+
q: asked.trim().toLowerCase(), jobs: [], statuses: [], accountingName: null, canDraft: false,
|
|
78
|
+
// Custom fields (docs/CUSTOM_FIELDS.md): the lab's fields, the work
|
|
79
|
+
// systems' stage fields, the people a person field can name, and each
|
|
80
|
+
// job's values by job and field.
|
|
81
|
+
fields: [], systemFields: [], people: [], values: new Map(),
|
|
82
|
+
// Each work system's "Ask before stage moves", read on the first move.
|
|
83
|
+
askBefore: new Map(),
|
|
84
|
+
};
|
|
56
85
|
const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
|
|
57
86
|
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 });
|
|
58
87
|
const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
|
|
88
|
+
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
|
|
59
89
|
main.classList.add('fx-main--wide');
|
|
60
90
|
main.append(
|
|
61
91
|
pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
|
|
62
|
-
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, search, toggle),
|
|
92
|
+
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, search, toggle)),
|
|
63
93
|
host);
|
|
64
94
|
|
|
65
95
|
const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
|
|
66
|
-
type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.layout === id),
|
|
67
|
-
onclick: () => {
|
|
96
|
+
type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
|
|
97
|
+
onclick: () => { saveLayout(id); change({ layout: id }); drawToggle(); },
|
|
68
98
|
}, label)));
|
|
69
99
|
drawToggle();
|
|
70
100
|
search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
|
|
71
101
|
|
|
72
102
|
const problemsOf = (job) => fixes(job.obligation, { accountingName: state.accountingName });
|
|
103
|
+
const valueKey = (job, fieldId) => `${job.sourceSystem}\u0000${job.sourceJobRef}\u0000${fieldId}`;
|
|
104
|
+
const valueOf = (job, fieldId) => state.values.get(valueKey(job, fieldId)) ?? null;
|
|
105
|
+
const groupable = () => state.fields.filter((f) => f.groupable);
|
|
106
|
+
const fieldOfGroup = (group) => groupable().find((f) => f.id === fieldIdOf(group)) ?? null;
|
|
107
|
+
// A work system whose stages Flow can hand a move to (Seazona).
|
|
108
|
+
const movesStages = (job) => state.systemFields.some((f) => f.source === job.sourceSystem);
|
|
109
|
+
// The lane a job shows in when grouped by status: a stage move the work
|
|
110
|
+
// system has not made yet puts it in its new lane.
|
|
111
|
+
const laneStatusOf = (job) => job.pendingMove?.to ?? job.status ?? NO_STATUS;
|
|
112
|
+
const statusOf = (job) => job.status ?? NO_STATUS;
|
|
113
|
+
const isLate = (job) => Boolean(job.dueOn && job.dueOn < today() && statusRank(job.status) < FINAL_RANK);
|
|
114
|
+
|
|
115
|
+
// A change to the view: draw it now, save it with the account shortly
|
|
116
|
+
// after, so a few quick clicks make one request.
|
|
117
|
+
let saveTimer = null;
|
|
118
|
+
function change(patch) {
|
|
119
|
+
state.view = normaliseJobsView({ ...state.view, ...patch });
|
|
120
|
+
if (ui.me) ui.me.views = { ...ui.me.views, jobs: state.view };
|
|
121
|
+
drawControls();
|
|
122
|
+
draw();
|
|
123
|
+
clearTimeout(saveTimer);
|
|
124
|
+
saveTimer = setTimeout(() => {
|
|
125
|
+
flow('/api/me/views/jobs', { method: 'PUT', body: JSON.stringify(state.view) })
|
|
126
|
+
.catch(() => ui.toast?.('Flow could not save your view. It applies on this page only.', 'danger'));
|
|
127
|
+
}, 500);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// One toolbar control: a button that opens a small menu under it. The menu
|
|
131
|
+
// is a popover, so a click outside or Escape closes it.
|
|
132
|
+
let menuCount = 0;
|
|
133
|
+
function menu(icon, build) {
|
|
134
|
+
const id = `fx-jobs-menu-${++menuCount}`;
|
|
135
|
+
const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
|
|
136
|
+
const button = h('button', { type: 'button', class: 'fx-chip', popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' });
|
|
137
|
+
const fill = () => panel.replaceChildren(...build(fill, () => panel.hidePopover()).filter(Boolean));
|
|
138
|
+
panel.addEventListener('toggle', (event) => {
|
|
139
|
+
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
140
|
+
if (event.newState !== 'open') return;
|
|
141
|
+
fill();
|
|
142
|
+
const at = button.getBoundingClientRect();
|
|
143
|
+
panel.style.top = `${Math.round(at.bottom + 6)}px`;
|
|
144
|
+
panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
node: [button, panel],
|
|
148
|
+
set(label, on) {
|
|
149
|
+
button.replaceChildren(svg(icon), h('span', {}, label));
|
|
150
|
+
button.classList.toggle('fx-chip--on', on);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// A choice in a menu: a row with a tick when it is chosen.
|
|
156
|
+
const option = (text, checked, onclick, role = 'menuitemradio') => h('button', {
|
|
157
|
+
type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
|
|
158
|
+
}, h('span', { class: 'fx-pop__tick' }, checked ? svg(TICK_ICON) : null), h('span', {}, text));
|
|
159
|
+
const heading = (text) => h('p', { class: 'fx-pop__head' }, text);
|
|
160
|
+
const DIR_WORDS = { total: ['Low to high', 'High to low'], due: ['Oldest first', 'Newest first'], board: ['As the lab system has it', 'Reversed'] };
|
|
161
|
+
|
|
162
|
+
const groupMenu = menu(GROUP_ICON, (_redraw, close) => [
|
|
163
|
+
heading('Group by'),
|
|
164
|
+
...Object.entries(GROUPS).map(([id, text]) => option(text, state.view.group === id, () => { change({ group: id }); close(); })),
|
|
165
|
+
groupable().length ? h('div', { class: 'fx-pop__sep' }) : null,
|
|
166
|
+
groupable().length ? heading('Custom fields') : null,
|
|
167
|
+
...groupable().map((f) => option(f.name, state.view.group === fieldGroup(f.id), () => { change({ group: fieldGroup(f.id) }); close(); })),
|
|
168
|
+
]);
|
|
169
|
+
const sortMenu = menu(SORT_ICON, (redraw) => {
|
|
170
|
+
const [up, down] = DIR_WORDS[state.view.sort] ?? ['A to Z', 'Z to A'];
|
|
171
|
+
return [
|
|
172
|
+
heading('Sort by'),
|
|
173
|
+
...Object.entries(SORTS).map(([id, text]) => option(text, state.view.sort === id, () => { change({ sort: id }); redraw(); })),
|
|
174
|
+
h('div', { class: 'fx-pop__sep' }),
|
|
175
|
+
option(up, state.view.dir === 'asc', () => { change({ dir: 'asc' }); redraw(); }),
|
|
176
|
+
option(down, state.view.dir === 'desc', () => { change({ dir: 'desc' }); redraw(); }),
|
|
177
|
+
];
|
|
178
|
+
});
|
|
179
|
+
const filterMenu = menu(FILTER_ICON, (redraw) => {
|
|
180
|
+
const hidden = new Set(state.view.hide);
|
|
181
|
+
return [
|
|
182
|
+
heading('Show only'),
|
|
183
|
+
option('Late jobs', state.view.late, () => { change({ late: !state.view.late }); redraw(); }, 'menuitemcheckbox'),
|
|
184
|
+
option('Jobs with a problem', state.view.problems, () => { change({ problems: !state.view.problems }); redraw(); }, 'menuitemcheckbox'),
|
|
185
|
+
h('div', { class: 'fx-pop__sep' }),
|
|
186
|
+
heading('Statuses'),
|
|
187
|
+
...orderStatuses(state.statuses, state.jobs).map((label) => option(label, !hidden.has(label), () => {
|
|
188
|
+
const next = new Set(hidden);
|
|
189
|
+
if (next.has(label)) next.delete(label); else next.add(label);
|
|
190
|
+
change({ hide: [...next] });
|
|
191
|
+
redraw();
|
|
192
|
+
}, 'menuitemcheckbox')),
|
|
193
|
+
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(); },
|
|
195
|
+
}, 'Clear filters')) : null,
|
|
196
|
+
];
|
|
197
|
+
});
|
|
198
|
+
controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node);
|
|
199
|
+
|
|
200
|
+
function drawControls() {
|
|
201
|
+
const { group, sort, dir } = state.view;
|
|
202
|
+
const field = fieldOfGroup(group);
|
|
203
|
+
groupMenu.set(`Group: ${field?.name ?? GROUPS[group] ?? GROUPS.status}`, group !== DEFAULT_VIEW.group && (Boolean(field) || Boolean(GROUPS[group])));
|
|
204
|
+
const sorted = sort !== DEFAULT_VIEW.sort || dir !== DEFAULT_VIEW.dir;
|
|
205
|
+
sortMenu.set(sorted ? `Sort: ${SORTS[sort]}${dir === 'desc' ? ' ↓' : ''}` : 'Sort', sorted);
|
|
206
|
+
const count = filterCount(state.view);
|
|
207
|
+
filterMenu.set(count ? `Filter · ${count}` : 'Filter', count > 0);
|
|
208
|
+
}
|
|
209
|
+
drawControls();
|
|
73
210
|
|
|
74
211
|
function draw() {
|
|
75
212
|
if (!state.jobs.length) {
|
|
213
|
+
main.classList.remove('fx-main--board');
|
|
76
214
|
host.replaceChildren(h('div', { class: 'fx-panel fx-empty' },
|
|
77
215
|
h('p', {}, 'No jobs yet. Jobs appear here once the lab system is connected under Integrations.'),
|
|
78
216
|
h('a', { class: 'fx-btn', href: '/invoices/new', onclick: ui.go }, 'Make an invoice by hand')));
|
|
79
217
|
return;
|
|
80
218
|
}
|
|
81
|
-
const
|
|
82
|
-
|
|
219
|
+
const found = filterJobs(state.jobs.filter((job) => matches(job, state.q)), state.view,
|
|
220
|
+
{ status: statusOf, late: isLate, problem: (job) => problemsOf(job).length > 0 });
|
|
221
|
+
const groups = grouped(sortJobs(found, state.view));
|
|
222
|
+
main.classList.toggle('fx-main--board', state.view.layout === 'board');
|
|
223
|
+
host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
|
|
83
224
|
}
|
|
84
225
|
|
|
85
226
|
async function load() {
|
|
86
227
|
try {
|
|
87
|
-
|
|
228
|
+
// Custom fields are extra: the board still shows without them.
|
|
229
|
+
const [body, context, fields, values] = await Promise.all([
|
|
230
|
+
flow('/api/invoicing/board'), flow('/api/invoicing/context'),
|
|
231
|
+
flow('/api/custom-fields').catch(() => null), flow('/api/custom-fields/values').catch(() => null),
|
|
232
|
+
]);
|
|
88
233
|
state.jobs = body.jobs;
|
|
89
234
|
state.statuses = body.statuses;
|
|
90
235
|
state.accountingName = body.accountingName;
|
|
91
236
|
state.canDraft = context.canIssue;
|
|
237
|
+
state.fields = fields?.fields ?? [];
|
|
238
|
+
state.systemFields = fields?.systemFields ?? [];
|
|
239
|
+
state.people = fields?.people ?? [];
|
|
240
|
+
state.values = new Map((values?.values ?? []).map((v) => [valueKey(v, v.fieldId), { value: v.value, updatedAt: v.updatedAt }]));
|
|
92
241
|
} catch (error) {
|
|
93
242
|
host.replaceChildren(notice(error.message, 'danger'));
|
|
94
243
|
return;
|
|
95
244
|
}
|
|
245
|
+
drawControls();
|
|
96
246
|
draw();
|
|
97
247
|
}
|
|
98
248
|
|
|
@@ -111,15 +261,16 @@ export async function renderJobs(main, ui) {
|
|
|
111
261
|
// One card ("Task card"). The card design may change, so its markup stays
|
|
112
262
|
// here and its CSS in one block of flow.css ("Job card"): client,
|
|
113
263
|
// practitioner, one date, total, and a warning mark when the job has a
|
|
114
|
-
// problem. Never the case number. A late job's date shows in red.
|
|
115
|
-
//
|
|
264
|
+
// problem. Never the case number. A late job's date shows in red. A stage
|
|
265
|
+
// move the work system has not made yet adds "Waiting for <system>".
|
|
116
266
|
function jobCard(job) {
|
|
117
267
|
const date = dateOf(job);
|
|
118
268
|
const day = job.dueOn ?? job.orderedOn ?? job.jobDate;
|
|
119
|
-
const late =
|
|
120
|
-
|
|
269
|
+
const late = isLate(job);
|
|
270
|
+
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
271
|
+
const card = h('article', {
|
|
121
272
|
class: 'fx-job', tabindex: '0', role: 'button', 'aria-label': `${clientOf(job)}, open the job`,
|
|
122
|
-
onclick: () => openJob(job),
|
|
273
|
+
onclick: () => { if (!dragged) openJob(job); },
|
|
123
274
|
onkeydown: (event) => { if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openJob(job); } },
|
|
124
275
|
},
|
|
125
276
|
h('div', { class: 'fx-job__client' }, clientOf(job)),
|
|
@@ -128,43 +279,317 @@ export async function renderJobs(main, ui) {
|
|
|
128
279
|
day ? h('span', { class: `fx-job__date${late ? ' fx-job__date--late' : ''}`, title: late ? `${date}, past due` : date }, svg(CALENDAR_ICON), showDate(day)) : null,
|
|
129
280
|
h('span', { class: 'fx-job__space' }),
|
|
130
281
|
problemMark(job),
|
|
131
|
-
h('span', { class: 'fx-job__amount' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))
|
|
282
|
+
h('span', { class: 'fx-job__amount' }, job.totalCents == null ? '—' : formatCents(job.totalCents))),
|
|
283
|
+
job.pendingMove ? h('span', { class: 'fx-job__wait', title: `Set the status to ${job.pendingMove.to} in ${system}. Flow then reads the change.` }, `Waiting for ${system}`) : null);
|
|
284
|
+
cardJobs.set(card, job);
|
|
285
|
+
return card;
|
|
132
286
|
}
|
|
133
287
|
|
|
134
|
-
// A
|
|
135
|
-
|
|
288
|
+
// A group's label: in its stage colour when the group is a status or an
|
|
289
|
+
// invoice stage, and how many jobs.
|
|
290
|
+
const stagePill = (label, count = null, rank = statusRank(label)) => h('span', { class: 'fx-stage', 'data-rank': String(rank) },
|
|
136
291
|
h('span', { class: 'fx-stage__label' }, label),
|
|
137
292
|
count == null ? null : h('span', { class: 'fx-stage__count' }, String(count)));
|
|
138
293
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
294
|
+
// The groups the view asks for, in order, each with its jobs. Grouped by
|
|
295
|
+
// status, every status the work system has shows, even with no job, except
|
|
296
|
+
// the ones the filter hides.
|
|
297
|
+
// A group's `drop` says what a card dropped in it changes: a custom
|
|
298
|
+
// field's value, or the job's stage in the work system. No `drop`: cards
|
|
299
|
+
// cannot be dropped there (invoice stage, client, practitioner).
|
|
300
|
+
function grouped(jobs) {
|
|
301
|
+
const { group } = state.view;
|
|
302
|
+
const field = fieldOfGroup(group);
|
|
303
|
+
if (field) {
|
|
304
|
+
const read = (job) => valueOf(job, field.id)?.value ?? null;
|
|
305
|
+
const lanes = fieldLanes(field, { people: state.people, jobs: state.jobs, valueOf: read });
|
|
306
|
+
const byLabel = new Map(lanes.map((lane) => [lane.label, lane]));
|
|
307
|
+
return groupJobs(jobs, lanes.map((lane) => lane.label), (job) => laneOf(lanes, read(job))).map((g) => {
|
|
308
|
+
const lane = byLabel.get(g.label);
|
|
309
|
+
return { ...g, rank: 'plain', color: lane?.color, drop: lane && !lane.archived ? { kind: 'field', field, value: lane.value } : null };
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (group === 'status' || !GROUPS[group]) {
|
|
313
|
+
const hidden = new Set(state.view.hide);
|
|
314
|
+
return groupJobs(jobs, orderStatuses(state.statuses, state.jobs).filter((label) => !hidden.has(label)), laneStatusOf)
|
|
315
|
+
.map((g) => ({ ...g, rank: statusRank(g.label), drop: g.label === NO_STATUS ? null : { kind: 'stage', status: g.label } }));
|
|
316
|
+
}
|
|
317
|
+
if (group === 'stage') {
|
|
318
|
+
const labelOf = (job) => STAGE_GROUPS[invoiceStage(job.obligation) ?? 'in_work'];
|
|
319
|
+
return groupJobs(jobs, Object.values(STAGE_GROUPS), labelOf).map((g) => ({ ...g, rank: STAGE_RANK[g.label] }));
|
|
320
|
+
}
|
|
321
|
+
const labelOf = group === 'client' ? clientOf : (job) => job.practitioner ?? 'Not listed';
|
|
322
|
+
const labels = [...new Set(jobs.map(labelOf))].sort((x, y) => x.localeCompare(y));
|
|
323
|
+
return groupJobs(jobs, labels, labelOf).map((g) => ({ ...g, rank: 'plain' }));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const totalOf = (jobs) => jobs.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
|
|
327
|
+
|
|
328
|
+
function board(groups) {
|
|
329
|
+
if (!groups.length) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
|
|
330
|
+
const element = h('div', { class: 'fx-board' }, groups.map(({ label, jobs, rank, color }, index) => h('section', { class: 'fx-board__col', 'aria-label': label, 'data-lane': String(index) },
|
|
331
|
+
h('header', { class: 'fx-board__head' },
|
|
332
|
+
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),
|
|
333
|
+
h('span', { class: 'fx-board__sum' }, jobs.length ? formatCents(totalOf(jobs)) : '')),
|
|
334
|
+
h('div', { class: 'fx-board__cards' }, jobs.length ? jobs.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')))));
|
|
335
|
+
if (groups.some((g) => g.drop)) enableDrag(element, groups);
|
|
336
|
+
return element;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Moves ────────────────────────────────────────────────────────────────
|
|
340
|
+
// Drag with a mouse or pen: press on a card, move 6px, and a copy of the
|
|
341
|
+
// card follows the pointer. A lane that takes the card lights up. Touch
|
|
342
|
+
// keeps scrolling the board; the job's panel has the moves instead.
|
|
343
|
+
const cardJobs = new WeakMap();
|
|
344
|
+
let dragged = false;
|
|
345
|
+
function enableDrag(element, groups) {
|
|
346
|
+
let drag = null;
|
|
347
|
+
const laneAt = (x, y) => document.elementFromPoint(x, y)?.closest?.('.fx-board__col') ?? null;
|
|
348
|
+
const canDrop = (lane, job) => {
|
|
349
|
+
if (!lane || !drag) return false;
|
|
350
|
+
const target = groups[Number(lane.dataset.lane)];
|
|
351
|
+
if (!target?.drop || Number(lane.dataset.lane) === drag.from) return false;
|
|
352
|
+
return target.drop.kind === 'field' || movesStages(job);
|
|
353
|
+
};
|
|
354
|
+
const end = () => {
|
|
355
|
+
drag?.ghost?.remove();
|
|
356
|
+
drag?.card.classList.remove('fx-job--lifted');
|
|
357
|
+
element.classList.remove('fx-board--dragging');
|
|
358
|
+
element.querySelectorAll('.fx-board__col--over').forEach((lane) => lane.classList.remove('fx-board__col--over'));
|
|
359
|
+
drag = null;
|
|
360
|
+
};
|
|
361
|
+
element.addEventListener('pointerdown', (event) => {
|
|
362
|
+
if (event.button !== 0 || event.pointerType === 'touch') return;
|
|
363
|
+
const card = event.target.closest('.fx-job');
|
|
364
|
+
if (!card || event.target.closest('button, a')) return;
|
|
365
|
+
const rect = card.getBoundingClientRect();
|
|
366
|
+
drag = { card, job: cardJobs.get(card), from: Number(card.closest('.fx-board__col').dataset.lane), x: event.clientX, y: event.clientY, dx: event.clientX - rect.left, dy: event.clientY - rect.top, width: rect.width, moved: false, over: null };
|
|
367
|
+
card.setPointerCapture(event.pointerId);
|
|
368
|
+
});
|
|
369
|
+
element.addEventListener('pointermove', (event) => {
|
|
370
|
+
if (!drag) return;
|
|
371
|
+
if (!drag.moved) {
|
|
372
|
+
if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 6) return;
|
|
373
|
+
drag.moved = true;
|
|
374
|
+
drag.ghost = drag.card.cloneNode(true);
|
|
375
|
+
drag.ghost.classList.add('fx-job--ghost');
|
|
376
|
+
drag.ghost.style.width = `${drag.width}px`;
|
|
377
|
+
document.body.append(drag.ghost);
|
|
378
|
+
drag.card.classList.add('fx-job--lifted');
|
|
379
|
+
element.classList.add('fx-board--dragging');
|
|
380
|
+
}
|
|
381
|
+
drag.ghost.style.transform = `translate(${event.clientX - drag.dx}px, ${event.clientY - drag.dy}px) rotate(1.5deg)`;
|
|
382
|
+
const lane = laneAt(event.clientX, event.clientY);
|
|
383
|
+
const over = canDrop(lane, drag.job) ? lane : null;
|
|
384
|
+
if (over !== drag.over) {
|
|
385
|
+
drag.over?.classList.remove('fx-board__col--over');
|
|
386
|
+
over?.classList.add('fx-board__col--over');
|
|
387
|
+
drag.over = over;
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
element.addEventListener('pointerup', (event) => {
|
|
391
|
+
if (!drag) return;
|
|
392
|
+
const { moved, over, job } = drag;
|
|
393
|
+
const target = over ? groups[Number(over.dataset.lane)] : null;
|
|
394
|
+
end();
|
|
395
|
+
if (!moved) return;
|
|
396
|
+
// The click that ends a drag does not open the job.
|
|
397
|
+
dragged = true;
|
|
398
|
+
setTimeout(() => { dragged = false; }, 0);
|
|
399
|
+
if (target) moveJob(job, target.drop);
|
|
400
|
+
event.preventDefault();
|
|
401
|
+
});
|
|
402
|
+
element.addEventListener('pointercancel', end);
|
|
403
|
+
element.addEventListener('keydown', (event) => { if (event.key === 'Escape' && drag) end(); });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function moveJob(job, drop) {
|
|
407
|
+
if (drop.kind === 'field') return setValue(job, drop.field, drop.value);
|
|
408
|
+
return moveStage(job, drop.status);
|
|
150
409
|
}
|
|
151
410
|
|
|
152
|
-
|
|
153
|
-
|
|
411
|
+
// Set one job's custom field value, in Flow only. The board shows it at
|
|
412
|
+
// once; `expectedUpdatedAt` makes a change someone else made first win,
|
|
413
|
+
// and the board then shows theirs.
|
|
414
|
+
async function setValue(job, field, value) {
|
|
415
|
+
const key = valueKey(job, field.id);
|
|
416
|
+
const before = state.values.get(key) ?? null;
|
|
417
|
+
if ((before?.value ?? null) === value) return;
|
|
418
|
+
if (value == null) state.values.delete(key); else state.values.set(key, { value, updatedAt: before?.updatedAt ?? null });
|
|
419
|
+
draw();
|
|
420
|
+
const keep = (saved) => {
|
|
421
|
+
if (saved?.value == null) state.values.delete(key); else state.values.set(key, { value: saved.value, updatedAt: saved.updatedAt });
|
|
422
|
+
};
|
|
423
|
+
try {
|
|
424
|
+
const { value: saved } = await flow('/api/custom-fields/values', {
|
|
425
|
+
method: 'PUT',
|
|
426
|
+
body: JSON.stringify({ sourceSystem: job.sourceSystem, sourceJobRef: job.sourceJobRef, fieldId: field.id, value, expectedUpdatedAt: before?.updatedAt ?? null }),
|
|
427
|
+
});
|
|
428
|
+
keep(saved);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (error.code === 'conflict' && error.details) {
|
|
431
|
+
keep(error.details.current);
|
|
432
|
+
ui.toast?.(`Someone changed ${field.name} on this job just now. The board shows their change.`, 'info');
|
|
433
|
+
} else {
|
|
434
|
+
if (before) state.values.set(key, before); else state.values.delete(key);
|
|
435
|
+
ui.toast?.(error.message, 'danger');
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
draw();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// "Ask before stage moves", this person's own setting in the work system's
|
|
442
|
+
// integration. On unless they said "Don't ask again".
|
|
443
|
+
async function askBefore(system) {
|
|
444
|
+
if (!state.askBefore.has(system)) {
|
|
445
|
+
try {
|
|
446
|
+
const { integrations } = await flow(`/api/work/my-settings?integration=${encodeURIComponent(system)}`);
|
|
447
|
+
state.askBefore.set(system, integrations[0]?.settings.find((s) => s.key === 'askBeforeStageMoves')?.value ?? true);
|
|
448
|
+
} catch {
|
|
449
|
+
state.askBefore.set(system, true);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return state.askBefore.get(system);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// A stage move in the work system. Seazona cannot take it through its
|
|
456
|
+
// API, so Flow says what to do there, records the move, and the card waits
|
|
457
|
+
// in its new lane until the sync reads the new status. A move back to the
|
|
458
|
+
// job's own status takes a waiting move back.
|
|
459
|
+
async function moveStage(job, status) {
|
|
460
|
+
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
461
|
+
const ref = { sourceSystem: job.sourceSystem, sourceJobRef: job.sourceJobRef };
|
|
462
|
+
const before = job.pendingMove ?? null;
|
|
463
|
+
if (status === (job.status ?? NO_STATUS)) {
|
|
464
|
+
if (!before) return;
|
|
465
|
+
job.pendingMove = null;
|
|
466
|
+
draw();
|
|
467
|
+
try {
|
|
468
|
+
await flow('/api/work/moves/cancel', { method: 'POST', body: JSON.stringify(ref) });
|
|
469
|
+
ui.toast?.(`Move taken back. The card follows ${system} again.`, 'success');
|
|
470
|
+
} catch (error) {
|
|
471
|
+
job.pendingMove = before;
|
|
472
|
+
ui.toast?.(error.message, 'danger');
|
|
473
|
+
draw();
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
let dontAsk = false;
|
|
478
|
+
if (await askBefore(job.sourceSystem)) {
|
|
479
|
+
const tick = h('input', { type: 'checkbox', name: 'dontAsk' });
|
|
480
|
+
const chip = (text) => h('span', { class: 'fx-move__stage' }, text);
|
|
481
|
+
const body = h('div', { class: 'fx-fields fx-move' },
|
|
482
|
+
h('p', { class: 'fx-move__path' }, chip(job.status ?? NO_STATUS), h('span', { 'aria-hidden': 'true' }, '→'), chip(status)),
|
|
483
|
+
h('p', { class: 'fx-move__note' }, `${system} cannot take this change from Flow. Make it in ${system}:`),
|
|
484
|
+
h('ol', { class: 'fx-steps' },
|
|
485
|
+
h('li', {}, openStep(job, system)),
|
|
486
|
+
h('li', {}, `Set its status to ${status}.`),
|
|
487
|
+
h('li', {}, `Flow shows the card as waiting until it reads the change. After a day it goes back.`)),
|
|
488
|
+
h('label', { class: 'fx-check' }, tick, h('span', {}, "Don't ask again")));
|
|
489
|
+
const ok = await ui.dialog({ title: `Move in ${system}`, body, confirm: 'Move card' });
|
|
490
|
+
if (!ok) return;
|
|
491
|
+
dontAsk = tick.checked;
|
|
492
|
+
}
|
|
493
|
+
job.pendingMove = { to: status, from: job.status, openUrl: job.link?.url ?? null, openExact: Boolean(job.link?.exact) };
|
|
494
|
+
draw();
|
|
495
|
+
try {
|
|
496
|
+
const { move } = await flow('/api/work/moves', { method: 'POST', body: JSON.stringify({ ...ref, toStatus: status }) });
|
|
497
|
+
job.pendingMove = { to: move.to, from: move.from, requestedAt: move.requestedAt, expiresAt: move.expiresAt, openUrl: move.openUrl, openExact: move.openExact };
|
|
498
|
+
ui.toast?.(`Waiting for ${system}. Set case ${move.caseRef} to ${move.to} there.`, 'success');
|
|
499
|
+
} catch (error) {
|
|
500
|
+
job.pendingMove = before;
|
|
501
|
+
ui.toast?.(error.message, 'danger');
|
|
502
|
+
}
|
|
503
|
+
if (dontAsk) {
|
|
504
|
+
state.askBefore.set(job.sourceSystem, false);
|
|
505
|
+
flow('/api/work/my-settings', { method: 'PUT', body: JSON.stringify({ integration: job.sourceSystem, key: 'askBeforeStageMoves', value: false }) })
|
|
506
|
+
.catch(() => ui.toast?.('Flow could not save "Don\'t ask again".', 'danger'));
|
|
507
|
+
}
|
|
508
|
+
draw();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// The first step of a stage move: a link that opens the order in the work
|
|
512
|
+
// system's web app. An exact link opens the order itself; otherwise it
|
|
513
|
+
// opens the lab's home page there, and the person finds the case.
|
|
514
|
+
function openStep(job, system) {
|
|
515
|
+
const caseRef = job.caseRef ?? job.sourceJobRef;
|
|
516
|
+
const url = job.link?.url ?? null;
|
|
517
|
+
if (!url) return `Open case ${caseRef} in ${system}.`;
|
|
518
|
+
const out = (text) => h('a', { class: 'fx-task__link', href: url, target: '_blank', rel: 'noopener' }, text);
|
|
519
|
+
return job.link.exact
|
|
520
|
+
? h('span', {}, out(`Open case ${caseRef} in ${system} ↗`))
|
|
521
|
+
: h('span', {}, out(`Open ${system} ↗`), ` and find case ${caseRef}.`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// The job panel's custom fields: one control each, saved when it changes.
|
|
525
|
+
function fieldControl(job, field) {
|
|
526
|
+
const current = valueOf(job, field.id)?.value ?? null;
|
|
527
|
+
const save = (value) => setValue(job, field, value);
|
|
528
|
+
const label = field.name;
|
|
529
|
+
if (field.type === 'dropdown' || field.type === 'person') {
|
|
530
|
+
const choices = field.type === 'person'
|
|
531
|
+
? state.people.map((p) => ({ value: p.id, label: p.name }))
|
|
532
|
+
: field.options.filter((o) => !o.archived || o.id === current).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name }));
|
|
533
|
+
const select = h('select', { class: 'fx-select fx-task__input', 'aria-label': label },
|
|
534
|
+
h('option', { value: '' }, NOT_SET), choices.map((c) => h('option', { value: c.value, selected: c.value === current }, c.label)));
|
|
535
|
+
select.addEventListener('change', () => save(select.value || null));
|
|
536
|
+
return select;
|
|
537
|
+
}
|
|
538
|
+
if (field.type === 'checkbox') {
|
|
539
|
+
const box = h('input', { type: 'checkbox', 'aria-label': label, checked: current === true });
|
|
540
|
+
box.addEventListener('change', () => save(box.checked ? true : null));
|
|
541
|
+
return box;
|
|
542
|
+
}
|
|
543
|
+
const input = h('input', {
|
|
544
|
+
class: 'fx-input fx-task__input', 'aria-label': label,
|
|
545
|
+
type: field.type === 'date' ? 'date' : field.type === 'number' ? 'number' : 'text',
|
|
546
|
+
value: current ?? '', maxlength: field.type === 'text' ? '500' : null, step: field.type === 'number' ? 'any' : null,
|
|
547
|
+
});
|
|
548
|
+
input.addEventListener('change', () => {
|
|
549
|
+
const raw = input.value.trim();
|
|
550
|
+
save(raw === '' ? null : field.type === 'number' ? Number(raw) : raw);
|
|
551
|
+
});
|
|
552
|
+
return input;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// The job panel's stage move: the same move as a drag, for a phone or a
|
|
556
|
+
// keyboard. A waiting move shows what to do, with Take back.
|
|
557
|
+
function stageControl(job) {
|
|
558
|
+
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
559
|
+
const stages = state.systemFields.find((f) => f.source === job.sourceSystem)?.options ?? [];
|
|
560
|
+
if (!stages.length) return null;
|
|
561
|
+
const select = h('select', { class: 'fx-select fx-task__input', 'aria-label': `Move to a ${system} stage` },
|
|
562
|
+
h('option', { value: '' }, 'Move to…'),
|
|
563
|
+
stages.filter((s) => s.name !== job.status).map((s) => h('option', { value: s.name }, s.name)));
|
|
564
|
+
select.addEventListener('change', () => { if (select.value) moveStage(job, select.value).then(() => select.closest('dialog')?.close()); });
|
|
565
|
+
const wait = job.pendingMove ? h('div', { class: 'fx-task__wait' },
|
|
566
|
+
h('span', {}, `Waiting for ${system}: set the status to ${job.pendingMove.to} there.`),
|
|
567
|
+
job.pendingMove.openUrl ? h('a', { class: 'fx-task__link', href: job.pendingMove.openUrl, target: '_blank', rel: 'noopener' }, `Open in ${system}`) : null,
|
|
568
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: (event) => { event.currentTarget.closest('dialog')?.close(); moveStage(job, job.status ?? NO_STATUS); } }, 'Take back')) : null;
|
|
569
|
+
return h('div', { class: 'fx-task__move' }, wait, select);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// The list: one block per group, with the group's label over its rows.
|
|
573
|
+
function list(groups, count) {
|
|
574
|
+
if (!count) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches the search or filter.'));
|
|
575
|
+
const row = (job) => h('tr', {
|
|
576
|
+
class: 'fx-row-link', tabindex: '0',
|
|
577
|
+
onclick: () => openJob(job),
|
|
578
|
+
onkeydown: (event) => { if (event.key === 'Enter') openJob(job); },
|
|
579
|
+
},
|
|
580
|
+
h('td', {}, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
|
|
581
|
+
h('td', { class: 'fx-dim' }, job.practitioner ?? '—'),
|
|
582
|
+
h('td', {}, job.status ?? NO_STATUS),
|
|
583
|
+
h('td', { class: 'fx-dim' }, dateOf(job) ?? '—'),
|
|
584
|
+
h('td', { class: 'fx-num fx-strong' }, job.totalCents == null ? '—' : formatCents(job.totalCents)));
|
|
154
585
|
return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
|
|
155
586
|
h('thead', {}, h('tr', {},
|
|
156
587
|
h('th', {}, 'Client'), h('th', {}, 'Practitioner'), h('th', {}, 'Stage'), h('th', {}, 'Date'),
|
|
157
588
|
h('th', { class: 'fx-num' }, 'Total'))),
|
|
158
|
-
|
|
159
|
-
class: 'fx-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
},
|
|
163
|
-
h('td', {}, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
|
|
164
|
-
h('td', { class: 'fx-dim' }, job.practitioner ?? '—'),
|
|
165
|
-
h('td', {}, job.status ?? NO_STATUS),
|
|
166
|
-
h('td', { class: 'fx-dim' }, dateOf(job) ?? '—'),
|
|
167
|
-
h('td', { class: 'fx-num fx-strong' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))))));
|
|
589
|
+
groups.filter((g) => g.jobs.length).map(({ label, jobs, rank }) => h('tbody', {},
|
|
590
|
+
h('tr', { class: 'fx-table__group' }, h('td', { colspan: '5' },
|
|
591
|
+
h('span', { class: 'fx-table__group-row' }, stagePill(label, jobs.length, rank), h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs)))))),
|
|
592
|
+
jobs.map(row)))));
|
|
168
593
|
}
|
|
169
594
|
|
|
170
595
|
// What a person can do with the job's invoice from here.
|
|
@@ -175,22 +600,23 @@ export async function renderJobs(main, ui) {
|
|
|
175
600
|
const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
|
|
176
601
|
const open = h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label);
|
|
177
602
|
// Only the job changed and the draft is not sent (ledger.js): one tap
|
|
178
|
-
// puts the job's new lines on Flow's own draft.
|
|
603
|
+
// puts the job's new lines on Flow's own draft. Nothing goes elsewhere.
|
|
179
604
|
if (!o.canUseNewLines) return [open];
|
|
605
|
+
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
180
606
|
return [h('button', {
|
|
181
607
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
182
608
|
onclick: async (event) => {
|
|
183
609
|
event.currentTarget.disabled = true;
|
|
184
610
|
try {
|
|
185
611
|
await flow(`/api/invoicing/jobs/${o.id}/use-new-lines`, { method: 'POST', body: '{}' });
|
|
186
|
-
ui.toast(
|
|
612
|
+
ui.toast(`The draft invoice now matches ${system}.`, 'success');
|
|
187
613
|
} catch (error) {
|
|
188
614
|
ui.toast(error.message, 'danger');
|
|
189
615
|
}
|
|
190
616
|
close();
|
|
191
617
|
await load();
|
|
192
618
|
},
|
|
193
|
-
},
|
|
619
|
+
}, `Adopt ${system} changes`), open];
|
|
194
620
|
}
|
|
195
621
|
// Flow's own stage: a person marks a job ready to invoice, or an
|
|
196
622
|
// automation does (Settings, Automations).
|
|
@@ -234,15 +660,16 @@ export async function renderJobs(main, ui) {
|
|
|
234
660
|
// The job's page: a large panel over the board, like a task page. A
|
|
235
661
|
// breadcrumb, the client's name, the stage, the job's fields, what to fix
|
|
236
662
|
// and its lines; its activity at the side (below on a phone). Esc, the X
|
|
237
|
-
// or a click outside closes it. `atFix` scrolls to what to fix.
|
|
238
|
-
//
|
|
663
|
+
// or a click outside closes it. `atFix` scrolls to what to fix. The job's
|
|
664
|
+
// custom fields can be changed here (in Flow only), and its stage moved
|
|
665
|
+
// (a hand-off to the work system).
|
|
239
666
|
function openJob(job, { atFix = false } = {}) {
|
|
240
667
|
const o = job.obligation;
|
|
241
668
|
const stage = invoiceStage(o);
|
|
242
669
|
const found = problemsOf(job);
|
|
243
670
|
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
244
671
|
const status = job.status ?? NO_STATUS;
|
|
245
|
-
const late =
|
|
672
|
+
const late = isLate(job);
|
|
246
673
|
const field = (term, value, attrs = {}) => (value == null || value === '' ? null
|
|
247
674
|
: h('div', {}, h('dt', {}, term), h('dd', attrs, value)));
|
|
248
675
|
const link = (attrs, text) => h('a', { class: 'fx-task__link', ...attrs }, text);
|
|
@@ -271,12 +698,18 @@ export async function renderJobs(main, ui) {
|
|
|
271
698
|
job.dueOn ? field('Ordered', job.orderedOn ? fullDate(job.orderedOn) : null) : null,
|
|
272
699
|
field('Total', job.totalCents == null ? null : formatCents(job.totalCents)),
|
|
273
700
|
field('Case number', job.caseRef ?? job.sourceJobRef, { class: 'fx-mono' }),
|
|
701
|
+
job.link?.url ? field(system, link({ href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`)) : null,
|
|
274
702
|
field(`${system} status`, job.status),
|
|
275
703
|
field(`${system} client`, job.workClientName && job.workClientName !== job.clientName ? job.workClientName : null),
|
|
276
704
|
field('Department', job.department),
|
|
277
705
|
field('Invoice', invoiceText),
|
|
278
706
|
o?.accountingUrl ? field(state.accountingName ?? 'Accounting', link({ href: o.accountingUrl, target: '_blank', rel: 'noopener' }, `Open in ${state.accountingName ?? 'the accounting system'}`)) : null),
|
|
279
707
|
fixBox,
|
|
708
|
+
state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
|
|
709
|
+
h('h3', {}, 'Fields'),
|
|
710
|
+
h('dl', { class: 'fx-task__fields' }, state.fields.map((f) => h('div', {}, h('dt', {}, f.name), h('dd', {}, fieldControl(job, f)))))) : null,
|
|
711
|
+
movesStages(job) ? h('section', { class: 'fx-task__custom', 'aria-label': `${system} stage` },
|
|
712
|
+
h('h3', {}, `${system} stage`), stageControl(job)) : null,
|
|
280
713
|
h('section', { class: 'fx-task__lines' },
|
|
281
714
|
h('h3', {}, 'Lines'),
|
|
282
715
|
lines.length ? h('table', {},
|