@arcforgelabs/flow 2026.10.34 → 2026.10.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/npm-shrinkwrap.json +81 -50
- package/package.json +7 -5
- package/server/api/custom-fields.js +5 -4
- package/server/api/invoicing.js +54 -12
- package/server/auth/people.js +35 -0
- package/server/auth/routes.js +14 -0
- package/server/core/custom-fields.js +8 -0
- package/server/core/job-board.js +2 -0
- package/server/core/ledger.js +82 -2
- package/server/db/migrations/0019_people_views.sql +1 -0
- package/server/db/migrations/meta/0019_snapshot.json +4295 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +5 -1
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/index.js +17 -5
- package/server/integrations/seazona/settings.js +24 -3
- package/server/integrations/seazona/stage-moves.js +10 -12
- package/server/release-channel.js +8 -6
- package/server/ui/custom-fields.js +223 -56
- package/server/ui/flow.css +155 -2
- package/server/ui/invoice-notes.js +1 -1
- package/server/ui/invoicing.js +160 -59
- 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 +481 -50
- package/server/ui/routes.js +11 -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,30 +59,154 @@ 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 fx-
|
|
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) {
|
|
@@ -79,22 +216,33 @@ export async function renderJobs(main, ui) {
|
|
|
79
216
|
h('a', { class: 'fx-btn', href: '/invoices/new', onclick: ui.go }, 'Make an invoice by hand')));
|
|
80
217
|
return;
|
|
81
218
|
}
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
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));
|
|
85
224
|
}
|
|
86
225
|
|
|
87
226
|
async function load() {
|
|
88
227
|
try {
|
|
89
|
-
|
|
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
|
+
]);
|
|
90
233
|
state.jobs = body.jobs;
|
|
91
234
|
state.statuses = body.statuses;
|
|
92
235
|
state.accountingName = body.accountingName;
|
|
93
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 }]));
|
|
94
241
|
} catch (error) {
|
|
95
242
|
host.replaceChildren(notice(error.message, 'danger'));
|
|
96
243
|
return;
|
|
97
244
|
}
|
|
245
|
+
drawControls();
|
|
98
246
|
draw();
|
|
99
247
|
}
|
|
100
248
|
|
|
@@ -113,15 +261,16 @@ export async function renderJobs(main, ui) {
|
|
|
113
261
|
// One card ("Task card"). The card design may change, so its markup stays
|
|
114
262
|
// here and its CSS in one block of flow.css ("Job card"): client,
|
|
115
263
|
// practitioner, one date, total, and a warning mark when the job has a
|
|
116
|
-
// problem. Never the case number. A late job's date shows in red.
|
|
117
|
-
//
|
|
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>".
|
|
118
266
|
function jobCard(job) {
|
|
119
267
|
const date = dateOf(job);
|
|
120
268
|
const day = job.dueOn ?? job.orderedOn ?? job.jobDate;
|
|
121
|
-
const late =
|
|
122
|
-
|
|
269
|
+
const late = isLate(job);
|
|
270
|
+
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
271
|
+
const card = h('article', {
|
|
123
272
|
class: 'fx-job', tabindex: '0', role: 'button', 'aria-label': `${clientOf(job)}, open the job`,
|
|
124
|
-
onclick: () => openJob(job),
|
|
273
|
+
onclick: () => { if (!dragged) openJob(job); },
|
|
125
274
|
onkeydown: (event) => { if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openJob(job); } },
|
|
126
275
|
},
|
|
127
276
|
h('div', { class: 'fx-job__client' }, clientOf(job)),
|
|
@@ -130,43 +279,317 @@ export async function renderJobs(main, ui) {
|
|
|
130
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,
|
|
131
280
|
h('span', { class: 'fx-job__space' }),
|
|
132
281
|
problemMark(job),
|
|
133
|
-
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;
|
|
134
286
|
}
|
|
135
287
|
|
|
136
|
-
// A
|
|
137
|
-
|
|
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) },
|
|
138
291
|
h('span', { class: 'fx-stage__label' }, label),
|
|
139
292
|
count == null ? null : h('span', { class: 'fx-stage__count' }, String(count)));
|
|
140
293
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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);
|
|
152
409
|
}
|
|
153
410
|
|
|
154
|
-
|
|
155
|
-
|
|
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)));
|
|
156
585
|
return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
|
|
157
586
|
h('thead', {}, h('tr', {},
|
|
158
587
|
h('th', {}, 'Client'), h('th', {}, 'Practitioner'), h('th', {}, 'Stage'), h('th', {}, 'Date'),
|
|
159
588
|
h('th', { class: 'fx-num' }, 'Total'))),
|
|
160
|
-
|
|
161
|
-
class: 'fx-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
},
|
|
165
|
-
h('td', {}, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
|
|
166
|
-
h('td', { class: 'fx-dim' }, job.practitioner ?? '—'),
|
|
167
|
-
h('td', {}, job.status ?? NO_STATUS),
|
|
168
|
-
h('td', { class: 'fx-dim' }, dateOf(job) ?? '—'),
|
|
169
|
-
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)))));
|
|
170
593
|
}
|
|
171
594
|
|
|
172
595
|
// What a person can do with the job's invoice from here.
|
|
@@ -177,22 +600,23 @@ export async function renderJobs(main, ui) {
|
|
|
177
600
|
const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
|
|
178
601
|
const open = h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label);
|
|
179
602
|
// Only the job changed and the draft is not sent (ledger.js): one tap
|
|
180
|
-
// 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.
|
|
181
604
|
if (!o.canUseNewLines) return [open];
|
|
605
|
+
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
182
606
|
return [h('button', {
|
|
183
607
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
184
608
|
onclick: async (event) => {
|
|
185
609
|
event.currentTarget.disabled = true;
|
|
186
610
|
try {
|
|
187
611
|
await flow(`/api/invoicing/jobs/${o.id}/use-new-lines`, { method: 'POST', body: '{}' });
|
|
188
|
-
ui.toast(
|
|
612
|
+
ui.toast(`The draft invoice now matches ${system}.`, 'success');
|
|
189
613
|
} catch (error) {
|
|
190
614
|
ui.toast(error.message, 'danger');
|
|
191
615
|
}
|
|
192
616
|
close();
|
|
193
617
|
await load();
|
|
194
618
|
},
|
|
195
|
-
},
|
|
619
|
+
}, `Adopt ${system} changes`), open];
|
|
196
620
|
}
|
|
197
621
|
// Flow's own stage: a person marks a job ready to invoice, or an
|
|
198
622
|
// automation does (Settings, Automations).
|
|
@@ -236,15 +660,16 @@ export async function renderJobs(main, ui) {
|
|
|
236
660
|
// The job's page: a large panel over the board, like a task page. A
|
|
237
661
|
// breadcrumb, the client's name, the stage, the job's fields, what to fix
|
|
238
662
|
// and its lines; its activity at the side (below on a phone). Esc, the X
|
|
239
|
-
// or a click outside closes it. `atFix` scrolls to what to fix.
|
|
240
|
-
//
|
|
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).
|
|
241
666
|
function openJob(job, { atFix = false } = {}) {
|
|
242
667
|
const o = job.obligation;
|
|
243
668
|
const stage = invoiceStage(o);
|
|
244
669
|
const found = problemsOf(job);
|
|
245
670
|
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
246
671
|
const status = job.status ?? NO_STATUS;
|
|
247
|
-
const late =
|
|
672
|
+
const late = isLate(job);
|
|
248
673
|
const field = (term, value, attrs = {}) => (value == null || value === '' ? null
|
|
249
674
|
: h('div', {}, h('dt', {}, term), h('dd', attrs, value)));
|
|
250
675
|
const link = (attrs, text) => h('a', { class: 'fx-task__link', ...attrs }, text);
|
|
@@ -273,12 +698,18 @@ export async function renderJobs(main, ui) {
|
|
|
273
698
|
job.dueOn ? field('Ordered', job.orderedOn ? fullDate(job.orderedOn) : null) : null,
|
|
274
699
|
field('Total', job.totalCents == null ? null : formatCents(job.totalCents)),
|
|
275
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,
|
|
276
702
|
field(`${system} status`, job.status),
|
|
277
703
|
field(`${system} client`, job.workClientName && job.workClientName !== job.clientName ? job.workClientName : null),
|
|
278
704
|
field('Department', job.department),
|
|
279
705
|
field('Invoice', invoiceText),
|
|
280
706
|
o?.accountingUrl ? field(state.accountingName ?? 'Accounting', link({ href: o.accountingUrl, target: '_blank', rel: 'noopener' }, `Open in ${state.accountingName ?? 'the accounting system'}`)) : null),
|
|
281
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,
|
|
282
713
|
h('section', { class: 'fx-task__lines' },
|
|
283
714
|
h('h3', {}, 'Lines'),
|
|
284
715
|
lines.length ? h('table', {},
|