@arcforgelabs/flow 2026.10.27 → 2026.10.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/server/api/invoicing.js +96 -40
- package/server/app.js +3 -1
- package/server/core/automations.js +14 -0
- package/server/core/invoice-rules.js +4 -2
- package/server/core/invoices.js +4 -2
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +12 -5
- package/server/core/line-mapping.js +337 -0
- package/server/core/pipeline.js +9 -3
- package/server/core/sending.js +4 -3
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +34 -13
- package/server/db/migrations/0011_invoice_rules.sql +11 -5
- package/server/db/migrations/0012_invoice_sending.sql +18 -9
- package/server/db/migrations/0013_line_mappings.sql +35 -0
- package/server/db/migrations/meta/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +43 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +5 -1
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/feed.js +62 -4
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/lanes.js +4 -1
- package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- package/server/integrations/xero/drafts.js +4 -1
- package/server/integrations/xero/lookup.js +6 -2
- package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +12 -6
- package/server/ui/app-page.js +6 -4
- package/server/ui/app.js +111 -8
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +155 -30
- package/server/ui/invoicing-settings.js +2 -0
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +220 -91
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/monitoring.js +15 -7
- package/server/ui/routes.js +1 -1
- package/server/ui/search.js +145 -0
package/server/ui/jobs.js
CHANGED
|
@@ -1,25 +1,33 @@
|
|
|
1
|
-
// Jobs (/jobs):
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// job
|
|
5
|
-
//
|
|
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. Cards are not
|
|
3
|
+
// dragged: a job moves when the work system changes its status. A click on a
|
|
4
|
+
// job opens its detail panel: the job, its lines, its invoice, and what to
|
|
5
|
+
// fix. Data comes from /api/invoicing/board (server/core/job-board.js); the
|
|
6
|
+
// rules are in server/ui/job-board.js.
|
|
6
7
|
|
|
7
8
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
9
|
+
import {
|
|
10
|
+
FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
|
|
11
|
+
} from '/assets/ui/job-board.js';
|
|
8
12
|
|
|
9
|
-
// Board columns, left to right. Held jobs sit with the jobs that need review.
|
|
10
|
-
const STAGES = [
|
|
11
|
-
{ id: 'work', label: 'In work', views: ['in_work'], tone: 'mute' },
|
|
12
|
-
{ id: 'review', label: 'Needs review', views: ['review_required', 'held'], tone: 'warn' },
|
|
13
|
-
{ id: 'pending', label: 'Checking', views: ['pending'], tone: 'mute' },
|
|
14
|
-
{ id: 'ready', label: 'Ready to bill', views: ['ready'], tone: 'info' },
|
|
15
|
-
{ id: 'drafted', label: 'Drafted', views: ['drafted'], tone: 'info' },
|
|
16
|
-
{ id: 'invoiced', label: 'Invoiced', views: ['invoiced'], tone: 'success' },
|
|
17
|
-
{ id: 'paid', label: 'Paid', views: ['paid'], tone: 'success' },
|
|
18
|
-
];
|
|
19
|
-
const STAGE_OF = Object.fromEntries(STAGES.flatMap((stage) => stage.views.map((view) => [view, stage])));
|
|
20
13
|
const LAYOUT_KEY = 'flow_jobs_layout';
|
|
14
|
+
const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
|
|
15
|
+
// A job's events (server/core/ledger.js), in plain words.
|
|
16
|
+
const ACTIVITY = {
|
|
17
|
+
observed: 'Flow read the job', source_changed: 'The job changed in the lab system', ready: 'Marked ready to invoice',
|
|
18
|
+
in_work: 'Put back in work', drafted: 'Draft invoice made', draft_refused: 'Draft invoice refused',
|
|
19
|
+
accounting_checked: 'Checked in the accounting system', payment_recorded: 'Payment recorded', paid: 'Paid in full',
|
|
20
|
+
held: 'Put on hold', review: 'Sent to review', released: 'Hold removed',
|
|
21
|
+
writeback_sent: 'Status sent to the lab system', writeback_failed: 'Status not sent to the lab system',
|
|
22
|
+
writeback_not_supported: 'The lab system does not take the status',
|
|
23
|
+
};
|
|
24
|
+
const BY = { person: 'A person', automation: 'An automation' };
|
|
21
25
|
|
|
22
26
|
const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
|
|
27
|
+
const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
|
|
28
|
+
const clientOf = (job) => job.clientName ?? 'No client';
|
|
29
|
+
// The one date a card shows: when the job is due, else when it was ordered.
|
|
30
|
+
const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
|
|
23
31
|
|
|
24
32
|
function readLayout() {
|
|
25
33
|
try { return localStorage.getItem(LAYOUT_KEY) === 'list' ? 'list' : 'board'; } catch { return 'board'; }
|
|
@@ -29,15 +37,27 @@ function saveLayout(layout) {
|
|
|
29
37
|
try { localStorage.setItem(LAYOUT_KEY, layout); } catch { /* the choice is only a convenience */ }
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
// The local calendar day, as yyyy-mm-dd, to mark late jobs.
|
|
41
|
+
const today = () => {
|
|
42
|
+
const now = new Date();
|
|
43
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
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>';
|
|
47
|
+
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>';
|
|
48
|
+
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>';
|
|
49
|
+
|
|
32
50
|
export async function renderJobs(main, ui) {
|
|
33
51
|
const { h, flow, pageHeader, notice } = ui;
|
|
34
|
-
|
|
52
|
+
// Search (search.js) opens a job with no invoice as /jobs?q=<case>.
|
|
53
|
+
const asked = new URLSearchParams(location.search).get('q') ?? '';
|
|
54
|
+
const state = { layout: readLayout(), q: asked.trim().toLowerCase(), jobs: [], statuses: [], accountingName: null, canDraft: false };
|
|
35
55
|
const toggle = h('div', { class: 'fx-seg', role: 'radiogroup', 'aria-label': 'Layout' });
|
|
36
|
-
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a case, client or practitioner', 'aria-label': 'Find a job' });
|
|
56
|
+
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 });
|
|
37
57
|
const host = h('div', { class: 'fx-jobs' }, h('div', { class: 'fx-skeleton' }));
|
|
38
58
|
main.classList.add('fx-main--wide');
|
|
39
59
|
main.append(
|
|
40
|
-
pageHeader('Jobs', { subtitle: 'Every
|
|
60
|
+
pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
|
|
41
61
|
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, search, toggle),
|
|
42
62
|
host);
|
|
43
63
|
|
|
@@ -46,12 +66,9 @@ export async function renderJobs(main, ui) {
|
|
|
46
66
|
onclick: () => { state.layout = id; saveLayout(id); drawToggle(); draw(); },
|
|
47
67
|
}, label)));
|
|
48
68
|
drawToggle();
|
|
49
|
-
search.addEventListener('input', () => { state.q = search.value.trim()
|
|
69
|
+
search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
|
|
50
70
|
|
|
51
|
-
const
|
|
52
|
-
? state.jobs.filter((job) => [job.caseRef, job.sourceJobRef, job.contactName, job.practitioner, job.invoiceNumber]
|
|
53
|
-
.some((value) => value && String(value).toLowerCase().includes(state.q)))
|
|
54
|
-
: state.jobs);
|
|
71
|
+
const problemsOf = (job) => fixes(job.obligation, { accountingName: state.accountingName });
|
|
55
72
|
|
|
56
73
|
function draw() {
|
|
57
74
|
if (!state.jobs.length) {
|
|
@@ -60,13 +77,16 @@ export async function renderJobs(main, ui) {
|
|
|
60
77
|
h('a', { class: 'fx-btn', href: '/invoices/new', onclick: ui.go }, 'Make an invoice by hand')));
|
|
61
78
|
return;
|
|
62
79
|
}
|
|
63
|
-
|
|
80
|
+
const shown = state.jobs.filter((job) => matches(job, state.q));
|
|
81
|
+
host.replaceChildren(state.layout === 'list' ? list(shown) : board(shown));
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
async function load() {
|
|
67
85
|
try {
|
|
68
|
-
const [
|
|
69
|
-
state.jobs = jobs;
|
|
86
|
+
const [body, context] = await Promise.all([flow('/api/invoicing/board'), flow('/api/invoicing/context')]);
|
|
87
|
+
state.jobs = body.jobs;
|
|
88
|
+
state.statuses = body.statuses;
|
|
89
|
+
state.accountingName = body.accountingName;
|
|
70
90
|
state.canDraft = context.canIssue;
|
|
71
91
|
} catch (error) {
|
|
72
92
|
host.replaceChildren(notice(error.message, 'danger'));
|
|
@@ -75,98 +95,207 @@ export async function renderJobs(main, ui) {
|
|
|
75
95
|
draw();
|
|
76
96
|
}
|
|
77
97
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
98
|
+
// The small warning mark of a job with a problem. It opens the job's page
|
|
99
|
+
// at its Fix box.
|
|
100
|
+
function problemMark(job) {
|
|
101
|
+
const found = problemsOf(job);
|
|
102
|
+
if (!found.length) return null;
|
|
103
|
+
const label = `Problem: ${problemSummary(found)}. Open to fix.`;
|
|
104
|
+
return h('button', {
|
|
105
|
+
class: 'fx-job__problem', type: 'button', 'aria-label': label, title: label,
|
|
106
|
+
onclick: (event) => { event.stopPropagation(); openJob(job, { atFix: true }); },
|
|
107
|
+
}, svg(WARN_ICON));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// One card ("Task card"). The card design may change, so its markup stays
|
|
111
|
+
// here and its CSS in one block of flow.css ("Job card"): client,
|
|
112
|
+
// practitioner, one date, total, and a warning mark when the job has a
|
|
113
|
+
// problem. Never the case number. A late job's date shows in red. No
|
|
114
|
+
// "move to the next stage" tick yet: Flow only reads the lab system.
|
|
115
|
+
function jobCard(job) {
|
|
116
|
+
const date = dateOf(job);
|
|
117
|
+
const day = job.dueOn ?? job.orderedOn ?? job.jobDate;
|
|
118
|
+
const late = job.dueOn && job.dueOn < today() && statusRank(job.status) < FINAL_RANK;
|
|
119
|
+
return h('article', {
|
|
120
|
+
class: 'fx-job', tabindex: '0', role: 'button', 'aria-label': `${clientOf(job)}, open the job`,
|
|
121
|
+
onclick: () => openJob(job),
|
|
122
|
+
onkeydown: (event) => { if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openJob(job); } },
|
|
123
|
+
},
|
|
124
|
+
h('div', { class: 'fx-job__client' }, clientOf(job)),
|
|
125
|
+
job.practitioner ? h('div', { class: 'fx-job__who' }, svg(PERSON_ICON), h('span', {}, job.practitioner)) : null,
|
|
126
|
+
h('div', { class: 'fx-job__foot' },
|
|
127
|
+
day ? h('span', { class: `fx-job__date${late ? ' fx-job__date--late' : ''}`, title: late ? `${date}, past due` : date }, svg(CALENDAR_ICON), showDate(day)) : null,
|
|
128
|
+
h('span', { class: 'fx-job__space' }),
|
|
129
|
+
problemMark(job),
|
|
130
|
+
h('span', { class: 'fx-job__amount' }, job.totalCents == null ? '—' : formatCents(job.totalCents))));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A column's label: the status, in its stage colour, and how many jobs.
|
|
134
|
+
const stagePill = (label, count = null) => h('span', { class: 'fx-stage', 'data-rank': String(statusRank(label)) },
|
|
135
|
+
h('span', { class: 'fx-stage__label' }, label),
|
|
136
|
+
count == null ? null : h('span', { class: 'fx-stage__count' }, String(count)));
|
|
137
|
+
|
|
138
|
+
function board(jobs) {
|
|
139
|
+
const columns = orderStatuses(state.statuses, state.jobs);
|
|
140
|
+
return h('div', { class: 'fx-board' }, columns.map((label) => {
|
|
141
|
+
const inColumn = jobs.filter((job) => (job.status ?? NO_STATUS) === label);
|
|
142
|
+
const total = inColumn.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
|
|
143
|
+
return h('section', { class: 'fx-board__col', 'aria-label': label },
|
|
144
|
+
h('header', { class: 'fx-board__head' },
|
|
145
|
+
stagePill(label, inColumn.length),
|
|
146
|
+
h('span', { class: 'fx-board__sum' }, inColumn.length ? formatCents(total) : '')),
|
|
147
|
+
h('div', { class: 'fx-board__cards' }, inColumn.length ? inColumn.map(jobCard) : h('p', { class: 'fx-board__none' }, 'None')));
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function list(jobs) {
|
|
152
|
+
if (!jobs.length) return h('div', { class: 'fx-panel' }, h('p', { class: 'fx-empty' }, 'No job matches that search.'));
|
|
153
|
+
return h('div', { class: 'fx-panel' }, h('table', { class: 'fx-table fx-table--jobs' },
|
|
154
|
+
h('thead', {}, h('tr', {},
|
|
155
|
+
h('th', {}, 'Client'), h('th', {}, 'Practitioner'), h('th', {}, 'Stage'), h('th', {}, 'Date'),
|
|
156
|
+
h('th', { class: 'fx-num' }, 'Total'))),
|
|
157
|
+
h('tbody', {}, jobs.map((job) => h('tr', {
|
|
158
|
+
class: 'fx-row-link', tabindex: '0',
|
|
159
|
+
onclick: () => openJob(job),
|
|
160
|
+
onkeydown: (event) => { if (event.key === 'Enter') openJob(job); },
|
|
161
|
+
},
|
|
162
|
+
h('td', {}, h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job))),
|
|
163
|
+
h('td', { class: 'fx-dim' }, job.practitioner ?? '—'),
|
|
164
|
+
h('td', {}, job.status ?? NO_STATUS),
|
|
165
|
+
h('td', { class: 'fx-dim' }, dateOf(job) ?? '—'),
|
|
166
|
+
h('td', { class: 'fx-num fx-strong' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))))));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// What a person can do with the job's invoice from here.
|
|
170
|
+
function actions(job, close) {
|
|
171
|
+
const o = job.obligation;
|
|
172
|
+
if (!o) return [];
|
|
173
|
+
if (o.invoiceId) {
|
|
174
|
+
const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
|
|
175
|
+
return [h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label)];
|
|
82
176
|
}
|
|
83
177
|
// Flow's own stage: a person marks a job ready to invoice, or an
|
|
84
178
|
// automation does (Settings, Automations).
|
|
85
|
-
if (
|
|
86
|
-
return h('button', {
|
|
87
|
-
class: 'fx-btn
|
|
179
|
+
if (o.view === 'in_work') {
|
|
180
|
+
return [h('button', {
|
|
181
|
+
class: 'fx-btn', type: 'button',
|
|
88
182
|
onclick: async (event) => {
|
|
89
|
-
event.stopPropagation();
|
|
90
183
|
event.currentTarget.disabled = true;
|
|
91
184
|
try {
|
|
92
|
-
await flow(`/api/invoicing/jobs/${
|
|
185
|
+
await flow(`/api/invoicing/jobs/${o.id}/ready`, { method: 'POST', body: JSON.stringify({ ready: true }) });
|
|
93
186
|
ui.toast('Marked ready to invoice.', 'success');
|
|
94
187
|
} catch (error) {
|
|
95
188
|
ui.toast(error.message, 'danger');
|
|
96
189
|
}
|
|
190
|
+
close();
|
|
97
191
|
await load();
|
|
98
192
|
},
|
|
99
|
-
}, 'Mark ready');
|
|
193
|
+
}, 'Mark ready to invoice')];
|
|
100
194
|
}
|
|
101
|
-
if (
|
|
102
|
-
return h('button', {
|
|
103
|
-
class: 'fx-btn fx-btn--primary
|
|
195
|
+
if (o.view !== 'ready' || !state.canDraft) return [];
|
|
196
|
+
return [h('button', {
|
|
197
|
+
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
104
198
|
onclick: async (event) => {
|
|
105
|
-
event.stopPropagation();
|
|
106
199
|
const button = event.currentTarget;
|
|
107
200
|
button.disabled = true;
|
|
108
201
|
try {
|
|
109
|
-
const { invoice } = await flow(`/api/invoicing/jobs/${
|
|
202
|
+
const { invoice } = await flow(`/api/invoicing/jobs/${o.id}/draft`, { method: 'POST', body: '{}' });
|
|
110
203
|
ui.toast('Draft invoice made.', 'success');
|
|
204
|
+
close();
|
|
111
205
|
ui.go(`/invoices/${invoice.id}`);
|
|
112
206
|
} catch (error) {
|
|
113
207
|
const reasons = error.details?.reasons;
|
|
114
208
|
ui.toast(reasons?.length ? `Not drafted: ${reasons.join('; ')}.` : error.message, 'danger');
|
|
115
|
-
|
|
209
|
+
close();
|
|
116
210
|
await load();
|
|
117
211
|
}
|
|
118
212
|
},
|
|
119
|
-
}, 'Draft invoice');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function card(job) {
|
|
123
|
-
const stage = STAGE_OF[job.view];
|
|
124
|
-
const reasons = ['review', 'pending'].includes(stage?.id) ? job.reasons?.join('; ') : '';
|
|
125
|
-
const why = reasons ? reasons[0].toUpperCase() + reasons.slice(1) + '.' : null;
|
|
126
|
-
return h('article', { class: `fx-job${stage?.id === 'review' ? ' fx-job--flag' : ''}` },
|
|
127
|
-
h('div', { class: 'fx-job__top' },
|
|
128
|
-
h('span', { class: 'fx-strong' }, job.caseRef ?? job.sourceJobRef),
|
|
129
|
-
h('span', { class: 'fx-job__amount' }, formatCents(job.totalCents))),
|
|
130
|
-
h('div', { class: 'fx-job__client' }, job.contactName ?? 'No client'),
|
|
131
|
-
h('div', { class: 'fx-job__meta' },
|
|
132
|
-
job.practitioner ? h('span', {}, job.practitioner) : null,
|
|
133
|
-
h('span', {}, `Shipped ${showDate(job.shippedOn)}`)),
|
|
134
|
-
why ? h('p', { class: 'fx-job__why' }, why) : null,
|
|
135
|
-
action(job));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function board(jobs) {
|
|
139
|
-
return h('div', { class: 'fx-board' }, STAGES.map((stage) => {
|
|
140
|
-
const inStage = jobs.filter((job) => stage.views.includes(job.view));
|
|
141
|
-
const total = inStage.reduce((sum, job) => sum + (job.totalCents ?? 0), 0);
|
|
142
|
-
return h('section', { class: 'fx-board__col', 'aria-label': stage.label },
|
|
143
|
-
h('header', { class: 'fx-board__head' },
|
|
144
|
-
h('span', { class: 'fx-board__dot', 'data-tone': stage.tone }),
|
|
145
|
-
h('span', { class: 'fx-strong' }, stage.label),
|
|
146
|
-
h('span', { class: 'fx-board__count' }, String(inStage.length)),
|
|
147
|
-
h('span', { class: 'fx-board__sum' }, inStage.length ? formatCents(total) : '')),
|
|
148
|
-
h('div', { class: 'fx-board__cards' }, inStage.length ? inStage.map(card) : h('p', { class: 'fx-board__none' }, 'None')));
|
|
149
|
-
}));
|
|
213
|
+
}, 'Draft invoice')];
|
|
150
214
|
}
|
|
151
215
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
216
|
+
// The job's page: a large panel over the board, like a task page. A
|
|
217
|
+
// breadcrumb, the client's name, the stage, the job's fields, what to fix
|
|
218
|
+
// and its lines; its activity at the side (below on a phone). Esc, the X
|
|
219
|
+
// or a click outside closes it. `atFix` scrolls to what to fix. Nothing is
|
|
220
|
+
// edited here yet: Flow only reads the lab system.
|
|
221
|
+
function openJob(job, { atFix = false } = {}) {
|
|
222
|
+
const o = job.obligation;
|
|
223
|
+
const stage = invoiceStage(o);
|
|
224
|
+
const found = problemsOf(job);
|
|
225
|
+
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
226
|
+
const status = job.status ?? NO_STATUS;
|
|
227
|
+
const late = job.dueOn && job.dueOn < today() && statusRank(job.status) < FINAL_RANK;
|
|
228
|
+
const field = (term, value, attrs = {}) => (value == null || value === '' ? null
|
|
229
|
+
: h('div', {}, h('dt', {}, term), h('dd', attrs, value)));
|
|
230
|
+
const link = (attrs, text) => h('a', { class: 'fx-task__link', ...attrs }, text);
|
|
231
|
+
const element = h('dialog', { class: 'fx-dialog fx-task', 'aria-label': clientOf(job) });
|
|
232
|
+
const close = () => element.close();
|
|
233
|
+
const invoiceText = o?.invoiceId
|
|
234
|
+
? link({ href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } },
|
|
235
|
+
`${o.invoiceStatus === 'draft' ? 'Draft' : 'Invoice'} ${o.invoiceNumber ?? ''}`.trim() + (stage ? ` · ${INVOICE_STAGES[stage]}` : ''))
|
|
236
|
+
: stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
|
|
237
|
+
const fixBox = found.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
|
|
238
|
+
h('h3', {}, 'Fix'),
|
|
239
|
+
found.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, `${f.text} ${f.todo}`)))) : null;
|
|
240
|
+
const lines = job.lines ?? [];
|
|
241
|
+
const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
|
|
242
|
+
element.append(h('div', { class: 'fx-task__grid' },
|
|
243
|
+
h('div', { class: 'fx-task__main' },
|
|
244
|
+
h('header', { class: 'fx-task__head' },
|
|
245
|
+
h('nav', { class: 'fx-task__crumb', 'aria-label': 'Breadcrumb' },
|
|
246
|
+
h('span', {}, 'Jobs'), h('span', { class: 'fx-task__slash', 'aria-hidden': 'true' }, '/'), h('span', {}, status)),
|
|
247
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', onclick: close }, '✕')),
|
|
248
|
+
h('h2', { class: 'fx-task__title' }, clientOf(job)),
|
|
249
|
+
h('div', { class: 'fx-task__stage' }, stagePill(status)),
|
|
250
|
+
h('dl', { class: 'fx-task__fields' },
|
|
251
|
+
field('Practitioner', job.practitioner),
|
|
252
|
+
field(job.dueOn ? 'Due' : 'Ordered', job.dueOn ? fullDate(job.dueOn) : job.orderedOn ? fullDate(job.orderedOn) : null, late ? { class: 'fx-task__late' } : {}),
|
|
253
|
+
job.dueOn ? field('Ordered', job.orderedOn ? fullDate(job.orderedOn) : null) : null,
|
|
254
|
+
field('Total', job.totalCents == null ? null : formatCents(job.totalCents)),
|
|
255
|
+
field('Case number', job.caseRef ?? job.sourceJobRef, { class: 'fx-mono' }),
|
|
256
|
+
field(`${system} status`, job.status),
|
|
257
|
+
field(`${system} client`, job.workClientName && job.workClientName !== job.clientName ? job.workClientName : null),
|
|
258
|
+
field('Department', job.department),
|
|
259
|
+
field('Invoice', invoiceText),
|
|
260
|
+
o?.accountingUrl ? field(state.accountingName ?? 'Accounting', link({ href: o.accountingUrl, target: '_blank', rel: 'noopener' }, `Open in ${state.accountingName ?? 'the accounting system'}`)) : null),
|
|
261
|
+
fixBox,
|
|
262
|
+
h('section', { class: 'fx-task__lines' },
|
|
263
|
+
h('h3', {}, 'Lines'),
|
|
264
|
+
lines.length ? h('table', {},
|
|
265
|
+
h('colgroup', {}, h('col', {}), h('col', { class: 'fx-task__col-code' }), h('col', { class: 'fx-task__col-amount' })),
|
|
266
|
+
h('thead', {}, h('tr', {}, h('th', {}, 'Item'), h('th', {}, 'Price code'), h('th', { class: 'fx-num' }, 'Amount'))),
|
|
267
|
+
h('tbody', {}, lines.map((line) => h('tr', {},
|
|
268
|
+
h('td', {}, line.description),
|
|
269
|
+
line.code && line.mapped ? h('td', { class: 'fx-mono' }, line.code)
|
|
270
|
+
: h('td', { class: 'fx-task__miss', title: line.code ? `${line.code} is not in the price list` : null }, line.code ? `${line.code}: not in the price list` : 'No price code'),
|
|
271
|
+
h('td', { class: 'fx-num' }, line.amountCents == null ? '—' : formatCents(line.amountCents))))),
|
|
272
|
+
h('tfoot', {}, h('tr', {}, h('td', {}, 'Total'), h('td', {}),
|
|
273
|
+
h('td', { class: 'fx-num' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))))
|
|
274
|
+
: h('p', { class: 'fx-dim' }, 'No lines.')),
|
|
275
|
+
h('div', { class: 'fx-task__actions' }, actions(job, close))),
|
|
276
|
+
h('aside', { class: 'fx-task__activity', 'aria-label': 'Activity' },
|
|
277
|
+
h('h3', {}, 'Activity'),
|
|
278
|
+
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.`))));
|
|
279
|
+
element.addEventListener('click', (event) => { if (event.target === element) close(); });
|
|
280
|
+
element.addEventListener('close', () => element.remove());
|
|
281
|
+
document.body.append(element);
|
|
282
|
+
element.showModal();
|
|
283
|
+
if (atFix && fixBox) fixBox.scrollIntoView({ block: 'start' });
|
|
284
|
+
if (!o) return;
|
|
285
|
+
const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
|
|
286
|
+
flow(`/api/invoicing/jobs/${o.id}/activity`).then(({ events }) => {
|
|
287
|
+
activity.replaceChildren(...(events.length ? events.map((e) => h('li', {},
|
|
288
|
+
`${ACTIVITY[e.kind] ?? e.kind.replaceAll('_', ' ')} (${BY[e.by] ?? SYSTEM_NAME[e.by] ?? e.by})`,
|
|
289
|
+
h('time', { datetime: e.createdAt }, when(e.createdAt))))
|
|
290
|
+
: [h('li', {}, 'Nothing yet.')]));
|
|
291
|
+
}).catch(() => activity.replaceChildren(h('li', {}, 'Flow could not read the activity.')));
|
|
169
292
|
}
|
|
170
293
|
|
|
171
294
|
await load();
|
|
172
295
|
}
|
|
296
|
+
|
|
297
|
+
function svg(markup) {
|
|
298
|
+
const span = document.createElement('span');
|
|
299
|
+
span.innerHTML = markup;
|
|
300
|
+
return span.firstChild;
|
|
301
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Settings, Invoicing, "Price codes" (server/core/line-mapping.js,
|
|
2
|
+
// /api/invoicing/line-labels and /line-mappings). Each work system line label
|
|
3
|
+
// that found no price item is one row: its text, its price, the jobs it holds
|
|
4
|
+
// back, and a menu of the lab's price items. A choice saves at once as a saved
|
|
5
|
+
// mapping for that label at that price, and the next pipeline run maps every
|
|
6
|
+
// job that has it. Everyone can read them; an owner or admin chooses. The
|
|
7
|
+
// caller passes in app.js's shared pieces as `ui`.
|
|
8
|
+
|
|
9
|
+
const money = (cents) => (cents === null || cents === undefined ? '' : `$${(cents / 100).toFixed(2)}`);
|
|
10
|
+
|
|
11
|
+
export function lineMappingSection(ui) {
|
|
12
|
+
const { h, flow, toast, notice, me } = ui;
|
|
13
|
+
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
14
|
+
const body = h('div', {}, h('div', { class: 'fx-skeleton' }));
|
|
15
|
+
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Price codes'), body);
|
|
16
|
+
|
|
17
|
+
const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
|
|
18
|
+
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
|
|
19
|
+
h('div', { class: 'fx-set__control' }, control));
|
|
20
|
+
|
|
21
|
+
async function load() {
|
|
22
|
+
let labels;
|
|
23
|
+
let items;
|
|
24
|
+
try {
|
|
25
|
+
[{ labels }, { priceItems: items }] = await Promise.all([flow('/api/invoicing/line-labels'), flow('/api/invoicing/price-items')]);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
body.replaceChildren(notice(error.message, 'danger'));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const waiting = labels.filter((l) => l.unmapped > 0);
|
|
31
|
+
if (!waiting.length) {
|
|
32
|
+
body.replaceChildren(row('Every line has a price code', 'Lines from Seazona find their price item by themselves.', h('span', { class: 'fx-dim' }, '')));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const options = [h('option', { value: '' }, 'Choose a price item…'),
|
|
36
|
+
...items.map((i) => h('option', { value: i.code }, `${i.code} · ${i.name} · ${money(i.unitAmountCents)}`))];
|
|
37
|
+
body.replaceChildren(...waiting.map((label) => {
|
|
38
|
+
const select = h('select', { class: 'fx-select', 'aria-label': `Price item for ${label.sample}`, disabled }, options.map((o) => o.cloneNode(true)));
|
|
39
|
+
select.addEventListener('change', async () => {
|
|
40
|
+
if (!select.value) return;
|
|
41
|
+
select.disabled = true;
|
|
42
|
+
try {
|
|
43
|
+
await flow('/api/invoicing/line-mappings', {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
body: JSON.stringify({ kind: 'saved', label: label.label, unitAmountCents: label.unitAmountCents ?? 0, itemCode: select.value }),
|
|
46
|
+
});
|
|
47
|
+
toast('Saved. Jobs with this line update in a few minutes.', 'success');
|
|
48
|
+
await load();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
toast(error.message, 'danger');
|
|
51
|
+
select.disabled = disabled;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
const lines = label.unmapped === 1 ? '1 line waits' : `${label.unmapped} lines wait`;
|
|
55
|
+
return row(label.sample, `${money(label.unitAmountCents)} · ${lines}`, select);
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
load();
|
|
59
|
+
return section;
|
|
60
|
+
}
|
package/server/ui/monitoring.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Sentry project (docs/SENTRY.md). Only while the Sentry integration is on:
|
|
3
3
|
// GET /api/monitoring says so and gives the project's public DSN. The SDK
|
|
4
4
|
// comes from Sentry's CDN, pinned and checked against its integrity hash.
|
|
5
|
-
// With the integration off, nothing loads and
|
|
5
|
+
// With the integration off, nothing loads and there is no "Report a problem".
|
|
6
6
|
|
|
7
7
|
function loadScript({ src, integrity }) {
|
|
8
8
|
return new Promise((resolve, reject) => {
|
|
@@ -16,9 +16,10 @@ function loadScript({ src, integrity }) {
|
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/** Resolves to a function that opens the "Report a problem" form, or null. */
|
|
20
|
+
export async function startMonitoring({ flow, person }) {
|
|
20
21
|
const config = await flow('/api/monitoring');
|
|
21
|
-
if (!config.on || config.service !== 'sentry') return;
|
|
22
|
+
if (!config.on || config.service !== 'sentry') return null;
|
|
22
23
|
await loadScript(config.script);
|
|
23
24
|
const Sentry = window.Sentry;
|
|
24
25
|
const integrations = config.feedback ? [Sentry.feedbackIntegration({
|
|
@@ -46,8 +47,15 @@ export async function startMonitoring({ flow, person, button }) {
|
|
|
46
47
|
});
|
|
47
48
|
// Who reported or met the error, so the lab can follow up.
|
|
48
49
|
Sentry.setUser({ id: person.id, email: person.email });
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
const feedback = config.feedback ? Sentry.getFeedback() : null;
|
|
51
|
+
if (!feedback) return null;
|
|
52
|
+
// One form, made on first use and opened again after that.
|
|
53
|
+
let form = null;
|
|
54
|
+
return async () => {
|
|
55
|
+
if (!form) {
|
|
56
|
+
form = await feedback.createForm();
|
|
57
|
+
form.appendToDom();
|
|
58
|
+
}
|
|
59
|
+
form.open();
|
|
60
|
+
};
|
|
53
61
|
}
|
package/server/ui/routes.js
CHANGED
|
@@ -20,7 +20,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
20
20
|
const logoDir = join(here, 'logos');
|
|
21
21
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
22
22
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
23
|
-
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'jobs.js', 'monitoring.js', 'flow-mark.svg']);
|
|
23
|
+
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'jobs.js', 'job-board.js', 'monitoring.js', 'search.js', 'flow-mark.svg']);
|
|
24
24
|
const MAX_AGE = '1h';
|
|
25
25
|
|
|
26
26
|
// Send one named file from dir. Passing dir as root limits the dotfile check
|