@arcforgelabs/flow 2026.10.43 → 2026.10.45
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/api/invoicing.js +53 -3
- package/server/auth/people.js +41 -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/release-channel.js +5 -2
- package/server/ui/app.js +53 -197
- package/server/ui/columns.js +286 -0
- package/server/ui/flow.css +230 -43
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +52 -29
- package/server/ui/invoicing.js +219 -72
- package/server/ui/job-view.js +276 -60
- package/server/ui/jobs.js +133 -137
- package/server/ui/kit.js +279 -0
- package/server/ui/list-views.js +115 -0
- package/server/ui/monitoring.js +51 -8
- package/server/ui/pager.js +67 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +27 -7
- 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/view-bar.js +176 -70
package/server/ui/jobs.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
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';
|
|
18
19
|
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
@@ -23,6 +24,8 @@ import {
|
|
|
23
24
|
COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
|
|
24
25
|
normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
|
|
25
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';
|
|
26
29
|
import { sidecar } from '/assets/ui/sidecar.js';
|
|
27
30
|
import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
|
|
28
31
|
|
|
@@ -30,7 +33,7 @@ const LAYOUT_KEY = 'flow_jobs_layout';
|
|
|
30
33
|
const FOLDED_KEY = 'flow_jobs_folded';
|
|
31
34
|
// The job panel's sidecar: the tab last open, or closed.
|
|
32
35
|
const SIDE_KEY = 'flow_job_side';
|
|
33
|
-
const SIDE_TABS = ['activity', '
|
|
36
|
+
const SIDE_TABS = ['activity', 'fix', 'closed'];
|
|
34
37
|
const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
|
|
35
38
|
// A job's events (server/core/ledger.js), in plain words.
|
|
36
39
|
const ACTIVITY = {
|
|
@@ -61,11 +64,6 @@ const shortDate = (iso) => {
|
|
|
61
64
|
if (days === -1) return 'Yesterday';
|
|
62
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' });
|
|
63
66
|
};
|
|
64
|
-
// A person's badge: their initials in a circle; the name on hover.
|
|
65
|
-
const initialsOf = (name) => {
|
|
66
|
-
const parts = (name ?? '?').split(/[\s._-]+/).filter(Boolean);
|
|
67
|
-
return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
|
|
68
|
-
};
|
|
69
67
|
const clientOf = (job) => job.clientName ?? 'No client';
|
|
70
68
|
// The one date a card shows: when the job is due, else when it was ordered.
|
|
71
69
|
const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
|
|
@@ -108,7 +106,7 @@ const CHEVRON_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidde
|
|
|
108
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>';
|
|
109
107
|
|
|
110
108
|
export async function renderJobs(main, ui) {
|
|
111
|
-
const { h, flow,
|
|
109
|
+
const { h, flow, listHead, listFrame, edgeFade, notice } = ui;
|
|
112
110
|
// Search (search.js) opens a job with no invoice as /jobs?q=<case>.
|
|
113
111
|
const asked = new URLSearchParams(location.search).get('q') ?? '';
|
|
114
112
|
// The person's own view (job-view.js), saved with their account. Before
|
|
@@ -126,23 +124,26 @@ export async function renderJobs(main, ui) {
|
|
|
126
124
|
// The list's ticked jobs (by jobKey), and its folded groups by group key.
|
|
127
125
|
ticked: new Set(), folded: readFolded(),
|
|
128
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);
|
|
129
132
|
const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
|
|
130
133
|
const find = findBox(h, { label: 'Find a case, client or practitioner', value: asked, oninput: (words) => { state.q = words.trim(); draw(); } });
|
|
131
134
|
const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
|
|
132
135
|
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
|
|
133
136
|
// In the list, ticked rows get the same floating bar as Invoices.
|
|
134
137
|
const bulk = bulkBar(ui, { label: 'Ticked jobs' });
|
|
135
|
-
main
|
|
136
|
-
|
|
137
|
-
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, find.element, toggle)),
|
|
138
|
-
host,
|
|
139
|
-
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);
|
|
140
140
|
|
|
141
141
|
const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
|
|
142
142
|
type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
|
|
143
143
|
onclick: () => { saveLayout(id); change({ layout: id }); drawToggle(); },
|
|
144
144
|
}, label)));
|
|
145
145
|
drawToggle();
|
|
146
|
+
views.draw();
|
|
146
147
|
|
|
147
148
|
const problemsOf = (job) => fixes(job.obligation);
|
|
148
149
|
const jobKey = (job) => `${job.sourceSystem}\u0000${job.sourceJobRef}`;
|
|
@@ -169,17 +170,13 @@ export async function renderJobs(main, ui) {
|
|
|
169
170
|
|
|
170
171
|
// A change to the view: draw it now, save it with the account shortly
|
|
171
172
|
// after, so a few quick clicks make one request.
|
|
172
|
-
|
|
173
|
+
const saveView = viewSaver(ui, 'jobs');
|
|
173
174
|
function change(patch) {
|
|
174
175
|
state.view = normaliseJobsView({ ...state.view, ...patch });
|
|
175
|
-
|
|
176
|
+
views.draw();
|
|
176
177
|
drawControls();
|
|
177
178
|
draw();
|
|
178
|
-
|
|
179
|
-
saveTimer = setTimeout(() => {
|
|
180
|
-
flow('/api/me/views/jobs', { method: 'PUT', body: JSON.stringify(state.view) })
|
|
181
|
-
.catch(() => ui.toast?.('Flow could not save your view. It applies on this page only.', 'danger'));
|
|
182
|
-
}, 500);
|
|
179
|
+
saveView(state.view);
|
|
183
180
|
}
|
|
184
181
|
|
|
185
182
|
// The chips (view-bar.js), shared with every list page.
|
|
@@ -241,11 +238,11 @@ export async function renderJobs(main, ui) {
|
|
|
241
238
|
}, { rows: true, clearLabel: 'Clear the sort' });
|
|
242
239
|
|
|
243
240
|
// ── Filters ──
|
|
244
|
-
//
|
|
245
|
-
// 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.
|
|
246
243
|
const filterFields = () => [
|
|
247
244
|
...Object.entries(FILTER_FIELDS).filter(([id]) => id !== 'assignee' || assigneeField())
|
|
248
|
-
.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 })),
|
|
249
246
|
{ group: 'Custom fields', options: customColumns().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
250
247
|
];
|
|
251
248
|
const kindOf = (field) => {
|
|
@@ -253,20 +250,23 @@ export async function renderJobs(main, ui) {
|
|
|
253
250
|
if (Object.hasOwn(FILTER_FIELDS, field)) return FILTER_FIELDS[field].kind;
|
|
254
251
|
return KIND_OF_TYPE[fieldById(fieldIdOf(field))?.type] ?? null;
|
|
255
252
|
};
|
|
256
|
-
|
|
257
|
-
|
|
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)] ?? {});
|
|
258
258
|
// The choices a choice or person filter offers: [{ value, label }].
|
|
259
259
|
function choicesOf(field) {
|
|
260
260
|
const people = () => [{ value: 'me', label: 'Me' }, ...state.people.map((p) => ({ value: p.id, label: p.name }))];
|
|
261
261
|
const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
|
|
262
|
-
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) }));
|
|
263
263
|
if (field === 'stage') return Object.values(STAGE_GROUPS).map((v) => ({ value: v, label: v }));
|
|
264
264
|
if (field === 'client') return labels(state.jobs.map(clientOf));
|
|
265
265
|
if (field === 'practitioner') return labels(state.jobs.map((job) => job.practitioner));
|
|
266
266
|
if (field === 'assignee') return people();
|
|
267
267
|
const custom = fieldById(fieldIdOf(field));
|
|
268
268
|
if (custom?.type === 'person') return people();
|
|
269
|
-
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 }));
|
|
270
270
|
}
|
|
271
271
|
// A job's value for a filter field, as the filter compares it.
|
|
272
272
|
function filterValue(job, field) {
|
|
@@ -350,6 +350,7 @@ export async function renderJobs(main, ui) {
|
|
|
350
350
|
const shown = new Set(found.map(jobKey));
|
|
351
351
|
for (const key of state.ticked) if (!shown.has(key)) state.ticked.delete(key);
|
|
352
352
|
state.found = found;
|
|
353
|
+
frame.count(`${found.length} ${found.length === 1 ? 'job' : 'jobs'}`);
|
|
353
354
|
host.replaceChildren(state.view.layout === 'list' ? list(groups, found.length) : board(groups));
|
|
354
355
|
drawBulk();
|
|
355
356
|
}
|
|
@@ -468,6 +469,9 @@ export async function renderJobs(main, ui) {
|
|
|
468
469
|
h('span', { class: 'fx-board__sum' }, jobs.length ? formatCents(totalOf(jobs)) : '')),
|
|
469
470
|
h('div', { class: 'fx-board__cards' }, jobs.length ? jobs.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')))));
|
|
470
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'] }));
|
|
471
475
|
return element;
|
|
472
476
|
}
|
|
473
477
|
|
|
@@ -664,10 +668,10 @@ export async function renderJobs(main, ui) {
|
|
|
664
668
|
const label = field.name;
|
|
665
669
|
if (field.type === 'dropdown' || field.type === 'person') {
|
|
666
670
|
const choices = field.type === 'person'
|
|
667
|
-
? state.people.map((p) => ({ value: p.id, label: p.name }))
|
|
671
|
+
? state.people.map((p) => ({ value: p.id, label: p.name, title: p.name }))
|
|
668
672
|
: field.options.filter((o) => !o.archived || o.id === current).map((o) => ({ value: o.id, label: o.archived ? `${o.name} (archived)` : o.name }));
|
|
669
673
|
const select = h('select', { class: 'fx-select fx-task__input', 'aria-label': label },
|
|
670
|
-
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))));
|
|
671
675
|
select.addEventListener('change', () => save(select.value || null));
|
|
672
676
|
return select;
|
|
673
677
|
}
|
|
@@ -705,9 +709,6 @@ export async function renderJobs(main, ui) {
|
|
|
705
709
|
return h('div', { class: 'fx-task__move' }, wait, select);
|
|
706
710
|
}
|
|
707
711
|
|
|
708
|
-
// A column's menu (a click on its head, or a right-click): sort by it,
|
|
709
|
-
// move it, hide it. Client always shows. `column` null is the + menu: the
|
|
710
|
-
// hidden columns, to add one back.
|
|
711
712
|
// What a column's head sorts by: a built-in key, or the column's custom
|
|
712
713
|
// field (Assignee is the lab's person field).
|
|
713
714
|
const SORT_OF = { client: 'client', practitioner: 'practitioner', due: 'due', ordered: 'ordered', shipped: 'shipped', total: 'total' };
|
|
@@ -757,65 +758,43 @@ export async function renderJobs(main, ui) {
|
|
|
757
758
|
on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
|
|
758
759
|
}
|
|
759
760
|
function sortMenuAt(event, by, label, on, at) {
|
|
760
|
-
const close = () => columnPop.hidePopover?.();
|
|
761
761
|
const { sorts } = state.view;
|
|
762
762
|
const flip = () => change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)) });
|
|
763
|
-
|
|
764
|
-
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(); } },
|
|
765
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))),
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
placeColumnPop(event);
|
|
769
|
-
}
|
|
770
|
-
const columnPop = h('div', { class: 'fx-pop', id: 'fx-jobs-colmenu', popover: 'auto', role: 'menu' });
|
|
771
|
-
document.body.append(columnPop);
|
|
772
|
-
// A popover opened while the right button is still down closes again on
|
|
773
|
-
// its release (light dismiss), so open it after the release.
|
|
774
|
-
function onRightButton(event, open) {
|
|
775
|
-
if (event.buttons & 2) addEventListener('pointerup', () => setTimeout(open), { once: true });
|
|
776
|
-
else open();
|
|
766
|
+
heads.menu.item('ban', 'Clear sort', () => change({ sorts: sorts.filter((_, n) => n !== at) })),
|
|
767
|
+
], event);
|
|
777
768
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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 = [];
|
|
785
786
|
const sort = sortOf(column.id);
|
|
786
787
|
if (sort) {
|
|
787
788
|
const [up, down] = dirWords(sort);
|
|
788
789
|
parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sorts: [{ by: sort, dir: 'asc' }] })),
|
|
789
790
|
item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sorts: [{ by: sort, dir: 'desc' }] })), h('div', { class: 'fx-pop__sep' }));
|
|
790
791
|
}
|
|
791
|
-
const rest = ids.filter((c) => c !== column.id);
|
|
792
|
-
parts.push(item('arrow-left-to-line', 'Move to start', () => change({ columns: [column.id, ...rest] })),
|
|
793
|
-
item('arrow-right-to-line', 'Move to end', () => change({ columns: [...rest, column.id] })));
|
|
794
792
|
if (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)) {
|
|
795
793
|
parts.push(item('settings', 'Edit custom field', () => ui.go('/custom-fields')));
|
|
796
794
|
}
|
|
797
|
-
parts
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
if (!column) {
|
|
801
|
-
const shown = new Set(ids);
|
|
802
|
-
const hidden = [
|
|
803
|
-
...builtInColumns().filter((id) => !shown.has(id)).map((id) => ({ id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id] })),
|
|
804
|
-
...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
|
|
805
|
-
];
|
|
806
|
-
parts.push(...hidden.map((c) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); change({ columns: [...ids, c.id] }); } }, c.label)));
|
|
807
|
-
if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
|
|
808
|
-
}
|
|
809
|
-
columnPop.replaceChildren(...parts);
|
|
810
|
-
placeColumnPop(event);
|
|
811
|
-
}
|
|
812
|
-
function placeColumnPop(event) {
|
|
813
|
-
columnPop.showPopover?.();
|
|
814
|
-
const x = event.clientX || event.currentTarget?.getBoundingClientRect?.().left || 0;
|
|
815
|
-
const y = event.clientY || event.currentTarget?.getBoundingClientRect?.().bottom || 0;
|
|
816
|
-
columnPop.style.top = `${Math.round(Math.min(y + 4, innerHeight - columnPop.offsetHeight - 8))}px`;
|
|
817
|
-
columnPop.style.left = `${Math.round(Math.max(8, Math.min(x, innerWidth - columnPop.offsetWidth - 8)))}px`;
|
|
818
|
-
}
|
|
795
|
+
return parts;
|
|
796
|
+
},
|
|
797
|
+
});
|
|
819
798
|
|
|
820
799
|
// The list's columns, in the order the person chose them. One column holds
|
|
821
800
|
// one field; each date has its own column.
|
|
@@ -823,25 +802,30 @@ export async function renderJobs(main, ui) {
|
|
|
823
802
|
const day = (iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
|
|
824
803
|
const late = (job) => (isLate(job) ? h('span', { class: 'fx-job__date--late', title: 'Past due' }, day(job.dueOn)) : day(job.dueOn));
|
|
825
804
|
const BUILT_IN = {
|
|
826
|
-
case: { kind: 'case',
|
|
827
|
-
practitioner: { kind: 'text',
|
|
828
|
-
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 ?? '—' },
|
|
829
808
|
stage: { kind: 'stage', cell: (job) => stagePill(statusOf(job)) },
|
|
830
809
|
assignee: { kind: 'person', cell: (job) => personBadge(job, assigneeField()) },
|
|
831
|
-
ordered: { kind: 'date',
|
|
832
|
-
due: { kind: 'date',
|
|
833
|
-
shipped: { kind: 'date',
|
|
834
|
-
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)) },
|
|
835
814
|
};
|
|
836
815
|
const allowed = new Set(builtInColumns());
|
|
837
|
-
|
|
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) => {
|
|
838
822
|
if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
|
|
839
823
|
const field = customColumns().find((f) => fieldGroup(f.id) === id);
|
|
840
824
|
if (!field) return null;
|
|
841
825
|
const cell = field.type === 'person' ? (job) => personBadge(job, field)
|
|
842
826
|
: field.type === 'date' ? (job) => day(valueOf(job, field.id)?.value ?? null) : (job) => fieldText(job, field);
|
|
843
|
-
return { id, label: field.name, kind: field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text', num: field.type === 'number',
|
|
844
|
-
}).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) }))];
|
|
845
829
|
}
|
|
846
830
|
|
|
847
831
|
// A person custom field's value as a badge (initials), the name on hover.
|
|
@@ -898,8 +882,7 @@ export async function renderJobs(main, ui) {
|
|
|
898
882
|
onkeydown: (event) => { if (event.key === 'Enter' && event.target === event.currentTarget) openJob(job); },
|
|
899
883
|
},
|
|
900
884
|
tickCell(setBox([job], `Tick ${clientOf(job)}${caseNumber(job) ? `, case ${caseNumber(job)}` : ''}`)),
|
|
901
|
-
|
|
902
|
-
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),
|
|
903
886
|
h('td', { class: 'fx-c-end' }));
|
|
904
887
|
const fold = (label) => {
|
|
905
888
|
const next = new Set(state.folded[state.view.group] ?? []);
|
|
@@ -917,22 +900,16 @@ export async function renderJobs(main, ui) {
|
|
|
917
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));
|
|
918
901
|
return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: () => fold(label) },
|
|
919
902
|
tickCell(setBox(jobs, `Tick every job in ${label}`)),
|
|
920
|
-
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))))));
|
|
921
904
|
};
|
|
922
|
-
return h('div', { class: 'fx-panel' },
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
h('span', { class: 'fx-th-cell' },
|
|
928
|
-
h('button', { type: 'button', class: 'fx-th', 'aria-haspopup': 'menu', title: 'Column menu (or right-click)', onclick: (event) => columnMenu(c, event) }, c.label),
|
|
929
|
-
sortButton(c.id, c.label)))),
|
|
930
|
-
h('th', { class: 'fx-th-add' }, h('button', {
|
|
931
|
-
type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => columnMenu(null, event),
|
|
932
|
-
}, '+')))),
|
|
933
|
-
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', {},
|
|
934
910
|
g.label == null ? null : head(g),
|
|
935
|
-
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
|
+
}));
|
|
936
913
|
}
|
|
937
914
|
|
|
938
915
|
// ── Bulk actions ─────────────────────────────────────────────────────────
|
|
@@ -1101,7 +1078,7 @@ export async function renderJobs(main, ui) {
|
|
|
1101
1078
|
if (!o) return [];
|
|
1102
1079
|
if (o.invoiceId) {
|
|
1103
1080
|
const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
|
|
1104
|
-
// A changed job is accepted in the panel's
|
|
1081
|
+
// A changed job is accepted in the panel's sidecar (Fix tab).
|
|
1105
1082
|
return [h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label)];
|
|
1106
1083
|
}
|
|
1107
1084
|
// Flow's own stage: a person marks a job ready to invoice, or an
|
|
@@ -1146,10 +1123,10 @@ export async function renderJobs(main, ui) {
|
|
|
1146
1123
|
// The job's page: a large panel over the board, like a task page. A
|
|
1147
1124
|
// breadcrumb, the client's name, the stage, the job's fields, what to fix
|
|
1148
1125
|
// and its lines. At the side (below on a phone), the sidecar: Activity,
|
|
1149
|
-
// and
|
|
1150
|
-
// Accept change). The sidecar closes, and the main area takes its room.
|
|
1151
|
-
// Esc, the X or a click outside closes the panel. `atFix`
|
|
1152
|
-
//
|
|
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
|
|
1153
1130
|
// be changed here (in Flow only), and its stage moved (a hand-off to the
|
|
1154
1131
|
// work system).
|
|
1155
1132
|
function openJob(job, { atFix = false } = {}) {
|
|
@@ -1157,7 +1134,7 @@ export async function renderJobs(main, ui) {
|
|
|
1157
1134
|
const stage = invoiceStage(o);
|
|
1158
1135
|
const found = problemsOf(job);
|
|
1159
1136
|
const choose = found.find((f) => f.go === 'choose') ?? null;
|
|
1160
|
-
// The job changed after its draft:
|
|
1137
|
+
// The job changed after its draft: its Fix shows the change.
|
|
1161
1138
|
const reviewable = Boolean(o?.invoiceId) && found.some((f) => f.go === 'review');
|
|
1162
1139
|
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
1163
1140
|
const status = job.status ?? NO_STATUS;
|
|
@@ -1173,14 +1150,24 @@ export async function renderJobs(main, ui) {
|
|
|
1173
1150
|
: choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
|
|
1174
1151
|
: stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
|
|
1175
1152
|
const rest = found.filter((f) => f.go !== 'choose');
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
+
}
|
|
1184
1171
|
// No invoice, and the match is unclear: one line and two buttons. The
|
|
1185
1172
|
// possible matches are compared on the Reconcile page, not here.
|
|
1186
1173
|
const chooseBox = choose ? h('section', { class: 'fx-task__choose', 'aria-label': 'Invoice' },
|
|
@@ -1209,25 +1196,22 @@ export async function renderJobs(main, ui) {
|
|
|
1209
1196
|
// Each fix's link: Flow's own invoice, the job in the work system, or
|
|
1210
1197
|
// the Flow page where the fix is made (job-board.js GO).
|
|
1211
1198
|
function fixLink(go, tip = null) {
|
|
1212
|
-
const
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
}
|
|
1216
|
-
if (go === 'review' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, '
|
|
1217
|
-
if (go === '
|
|
1218
|
-
if (go === '
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
if (go === 'price-list') return inFlow('/price-list', 'Open price list');
|
|
1222
|
-
if (go === 'invoicing-settings') return inFlow('/invoicing-settings', 'Open invoice rules');
|
|
1223
|
-
if (go === 'integrations' && ui.me?.person?.role === 'owner') return inFlow('/integrations', 'Open integrations');
|
|
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');
|
|
1224
1208
|
return null;
|
|
1225
1209
|
}
|
|
1226
1210
|
const lines = job.lines ?? [];
|
|
1227
1211
|
const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
|
|
1228
1212
|
const activityBody = h('div', { class: 'fx-task__activity' },
|
|
1229
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.`));
|
|
1230
|
-
//
|
|
1214
|
+
// The change: as the invoice page shows it (job-change.js),
|
|
1231
1215
|
// read from the invoice when the sidecar first needs it.
|
|
1232
1216
|
const reviewBody = h('div', { class: 'fx-task__review' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
1233
1217
|
let reviewRead = false;
|
|
@@ -1251,19 +1235,32 @@ export async function renderJobs(main, ui) {
|
|
|
1251
1235
|
close();
|
|
1252
1236
|
if (fresh) openJob(fresh);
|
|
1253
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
|
+
}
|
|
1254
1249
|
const sideButton = h('button', {
|
|
1255
1250
|
class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Side panel', title: 'Side panel', 'aria-pressed': 'false',
|
|
1256
|
-
onclick: () => (side.isOpen() ? side.close() : side.open(readSide() === '
|
|
1251
|
+
onclick: () => (side.isOpen() ? side.close() : side.open(readSide() === 'fix' && hasFix ? 'fix' : 'activity')),
|
|
1257
1252
|
}, lucide(h, 'panel-right'));
|
|
1258
1253
|
const side = sidecar(h, {
|
|
1259
1254
|
label: 'Job side panel',
|
|
1260
1255
|
tabs: [
|
|
1261
1256
|
{ id: 'activity', label: 'Activity', icon: 'history', render: () => activityBody },
|
|
1262
|
-
|
|
1257
|
+
hasFix ? { id: 'fix', label: 'Fix', icon: 'triangle-alert', mark: true, render: () => { picked ??= 0; return fixBody(); } } : null,
|
|
1263
1258
|
].filter(Boolean),
|
|
1264
1259
|
onChange: (tab) => {
|
|
1265
1260
|
saveSide(tab);
|
|
1266
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)));
|
|
1267
1264
|
},
|
|
1268
1265
|
});
|
|
1269
1266
|
side.element.classList.add('fx-task__side');
|
|
@@ -1275,7 +1272,7 @@ export async function renderJobs(main, ui) {
|
|
|
1275
1272
|
h('div', { class: 'fx-task__tools' }, sideButton,
|
|
1276
1273
|
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: close }, '✕'))),
|
|
1277
1274
|
h('h2', { class: 'fx-task__title' }, clientOf(job)),
|
|
1278
|
-
h('div', { class: 'fx-task__stage' }, stagePill(status)),
|
|
1275
|
+
h('div', { class: 'fx-task__stage' }, stagePill(status), fixChips),
|
|
1279
1276
|
h('dl', { class: 'fx-task__fields' },
|
|
1280
1277
|
field('Practitioner', job.practitioner),
|
|
1281
1278
|
field(job.dueOn ? 'Due' : 'Ordered', job.dueOn ? fullDate(job.dueOn) : job.orderedOn ? fullDate(job.orderedOn) : null, late ? { class: 'fx-task__late' } : {}),
|
|
@@ -1290,7 +1287,6 @@ export async function renderJobs(main, ui) {
|
|
|
1290
1287
|
// link to an accounting system (its Linked card).
|
|
1291
1288
|
field('Invoice', invoiceText)),
|
|
1292
1289
|
chooseBox,
|
|
1293
|
-
fixBox,
|
|
1294
1290
|
state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
|
|
1295
1291
|
h('h3', {}, 'Custom fields'),
|
|
1296
1292
|
h('dl', { class: 'fx-task__fields' }, state.fields.map((f) => h('div', {}, h('dt', {}, f.name), h('dd', {}, fieldControl(job, f)))))) : null,
|
|
@@ -1316,9 +1312,9 @@ export async function renderJobs(main, ui) {
|
|
|
1316
1312
|
document.body.append(element);
|
|
1317
1313
|
element.showModal();
|
|
1318
1314
|
const last = readSide();
|
|
1319
|
-
if (atFix &&
|
|
1320
|
-
else if (last !== 'closed') side.open(last === '
|
|
1321
|
-
if (atFix &&
|
|
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' });
|
|
1322
1318
|
if (!o) return;
|
|
1323
1319
|
const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
|
|
1324
1320
|
flow(`/api/invoicing/jobs/${o.id}/activity`).then(({ events }) => {
|