@arcforgelabs/flow 2026.10.41 → 2026.10.43
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +15 -5
- package/server/core/invoice-rules.js +49 -8
- package/server/core/sending.js +33 -5
- package/server/integrations/sentry/index.js +9 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +74 -29
- package/server/ui/automations.js +9 -3
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +77 -37
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoice-rules.js +38 -13
- package/server/ui/invoicing-settings.js +69 -35
- package/server/ui/invoicing.js +17 -51
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/jobs.js +88 -32
- package/server/ui/monitoring.js +1 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- package/server/ui/routes.js +1 -1
- package/server/ui/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/xero-import.js +7 -10
package/server/ui/invoicing.js
CHANGED
|
@@ -9,7 +9,7 @@ import { alertStrip } from '/assets/ui/alerts.js';
|
|
|
9
9
|
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
10
10
|
import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
|
|
11
11
|
import { caseNumber } from '/assets/ui/job-board.js';
|
|
12
|
-
import {
|
|
12
|
+
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
13
13
|
import { findBox } from '/assets/ui/view-bar.js';
|
|
14
14
|
|
|
15
15
|
// The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
|
|
@@ -201,7 +201,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
201
201
|
});
|
|
202
202
|
if (!ok) return;
|
|
203
203
|
await post('send-ready', { issueDate: localDate(), invoiceIds: ticked },
|
|
204
|
-
(res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, 'success']);
|
|
204
|
+
(res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.held ? ` ${res.held} failed a check and went to Pending.` : ''}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, res.held ? 'info' : 'success']);
|
|
205
205
|
},
|
|
206
206
|
});
|
|
207
207
|
}
|
|
@@ -836,15 +836,12 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
836
836
|
dueDate.addEventListener('change', syncIssue);
|
|
837
837
|
syncIssue();
|
|
838
838
|
|
|
839
|
-
// A new invoice can start from a job that has no invoice yet: a
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
const fromJob = isNew && context.canIssue
|
|
844
|
-
h('
|
|
845
|
-
h('span', { class: 'fx-strong' }, 'Billing a job?'),
|
|
846
|
-
h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
|
|
847
|
-
h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
|
|
839
|
+
// A new invoice can start from a job that has no invoice yet: a button in
|
|
840
|
+
// the header. Flow drafts it the way the invoicing rules do (client,
|
|
841
|
+
// Reference, lines) and links it to the job. An invoice with no job is
|
|
842
|
+
// fine too.
|
|
843
|
+
const fromJob = isNew && context.canIssue
|
|
844
|
+
? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => pickJob(ui) }, 'From a job') : null;
|
|
848
845
|
|
|
849
846
|
// A draft with no problem is in Ready by itself; one with a problem is
|
|
850
847
|
// Pending. A changed job is fixed at its source or accepted here.
|
|
@@ -853,47 +850,18 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
853
850
|
// changed. Other problems stay one line.
|
|
854
851
|
const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
|
|
855
852
|
const problems = [
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
// Accepting changes the draft for the business: owners and admins only.
|
|
859
|
-
onAccept: !context.canIssue ? null : async () => {
|
|
860
|
-
const system = jobChange.systemName;
|
|
861
|
-
const lost = [jobChange.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, changed() ? 'Unsaved changes are lost.' : null].filter(Boolean);
|
|
862
|
-
if (lost.length) {
|
|
863
|
-
const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
|
|
864
|
-
if (!ok) return;
|
|
865
|
-
}
|
|
866
|
-
try {
|
|
867
|
-
const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
|
|
868
|
-
toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
|
|
869
|
-
redraw(res.invoice);
|
|
870
|
-
} catch (error) {
|
|
871
|
-
toast(error.message, 'danger');
|
|
872
|
-
}
|
|
873
|
-
},
|
|
874
|
-
// Reads this one job from the work system again, then every check runs.
|
|
875
|
-
onRecheck: async () => {
|
|
876
|
-
try {
|
|
877
|
-
const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/recheck`, { method: 'POST', body: '{}' });
|
|
878
|
-
toast(res.invoice?.jobChange ? `Still changed in ${jobChange.systemName}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
|
|
879
|
-
if (res.invoice) redraw(res.invoice);
|
|
880
|
-
} catch (error) {
|
|
881
|
-
toast(error.message, 'danger');
|
|
882
|
-
}
|
|
883
|
-
},
|
|
884
|
-
}) : null,
|
|
853
|
+
// Accepting changes the draft for the business: owners and admins only.
|
|
854
|
+
jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
|
|
885
855
|
otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
|
|
886
856
|
];
|
|
887
857
|
|
|
888
858
|
return [
|
|
889
859
|
navBar(ui, invoice),
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton)),
|
|
860
|
+
ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
|
|
861
|
+
actions: [fromJob, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton].filter(Boolean),
|
|
862
|
+
}),
|
|
863
|
+
draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
|
|
895
864
|
...problems,
|
|
896
|
-
fromJob,
|
|
897
865
|
h('div', { class: 'fx-invoice__grid' },
|
|
898
866
|
h('div', { class: 'fx-invoice__main' },
|
|
899
867
|
h('section', { class: 'fx-card fx-invoice__head' },
|
|
@@ -999,10 +967,8 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
999
967
|
navBar(ui, invoice),
|
|
1000
968
|
h('header', { class: 'fx-ph' },
|
|
1001
969
|
h('div', { class: 'fx-ph__text' },
|
|
1002
|
-
h('
|
|
1003
|
-
|
|
1004
|
-
h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
|
|
1005
|
-
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · ${showDate(invoice.issueDate)}`)),
|
|
970
|
+
h('h1', { class: 'fx-title' }, invoice.number),
|
|
971
|
+
h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
|
|
1006
972
|
h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
|
|
1007
973
|
h('div', { class: 'fx-invoice__grid' },
|
|
1008
974
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -1072,7 +1038,7 @@ function xeroCard(invoice, ui, context, status, reload) {
|
|
|
1072
1038
|
}
|
|
1073
1039
|
|
|
1074
1040
|
// Send to Xero (POST /api/xero/invoices/push). The customer's send mode
|
|
1075
|
-
// (Settings,
|
|
1041
|
+
// (Settings, Rules) decides what Xero gets: a draft that a person approves
|
|
1076
1042
|
// there, or an approved invoice that Xero emails to the customer.
|
|
1077
1043
|
function sendToXero(invoice, ui, context) {
|
|
1078
1044
|
const { h, flow, toast } = ui;
|
package/server/ui/job-board.js
CHANGED
|
@@ -93,7 +93,10 @@ function reasonFix(reason) {
|
|
|
93
93
|
if (reason === 'the work system report is out of date') {
|
|
94
94
|
return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.', go: 'integrations' };
|
|
95
95
|
}
|
|
96
|
-
|
|
96
|
+
// Review opens the job's own sidecar: what changed, and Accept change.
|
|
97
|
+
if (reason === 'the job changed after its invoice was made') {
|
|
98
|
+
return { label: 'Job changed', text: 'Changed after the draft was made.', todo: 'Fix it in the work system, or accept the change.', go: 'review' };
|
|
99
|
+
}
|
|
97
100
|
if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.', 'work');
|
|
98
101
|
if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.', 'work');
|
|
99
102
|
// The matcher found invoices that may be this job's, but no exact link.
|
|
@@ -134,27 +137,36 @@ function draftFix(problem) {
|
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
// Where a fix's link goes: Flow's invoice, the job in the work system, a
|
|
137
|
-
// Flow settings page,
|
|
138
|
-
// (jobs.js draws them).
|
|
139
|
-
export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'choose'];
|
|
140
|
+
// Flow settings page, `review`: the job's Review sidecar, or `choose`: Make
|
|
141
|
+
// invoice and Link invoice buttons (jobs.js draws them).
|
|
142
|
+
export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'review', 'choose'];
|
|
140
143
|
|
|
141
144
|
// What a person must fix on a job, in order: [{ label, text, todo, go }];
|
|
142
145
|
// `todo` is null when the buttons are the instruction.
|
|
143
|
-
// Empty unless invoiceStage is Problem.
|
|
146
|
+
// Empty unless invoiceStage is Problem. One fact is one fix: a job in review
|
|
147
|
+
// already says why, so its draft's copy of that hold (sending.js,
|
|
148
|
+
// `job_review`) is left out, and two fixes with the same words show once.
|
|
144
149
|
export function fixes(o) {
|
|
145
150
|
if (invoiceStage(o) !== 'problem') return [];
|
|
146
151
|
const found = [];
|
|
147
152
|
if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
|
|
148
153
|
if (o.view === 'ready') {
|
|
149
154
|
found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
|
|
150
|
-
label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings,
|
|
155
|
+
label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Rules.', go: 'invoicing-settings',
|
|
151
156
|
})));
|
|
152
157
|
}
|
|
153
158
|
if (o.invoiceStatus === 'void') {
|
|
154
159
|
found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
|
|
155
160
|
}
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
const held = o.view === 'review_required' && o.holdState === 'review';
|
|
162
|
+
found.push(...(o.invoiceProblems ?? []).filter((p) => !(held && p.code === 'job_review')).map((p) => draftFix(p)));
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
return found.filter((f) => {
|
|
165
|
+
const key = `${f.label}\u0000${f.text}`;
|
|
166
|
+
if (seen.has(key)) return false;
|
|
167
|
+
seen.add(key);
|
|
168
|
+
return true;
|
|
169
|
+
});
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
// The short words for a job's problems: the first label, and "+N" for more.
|
package/server/ui/job-change.js
CHANGED
|
@@ -87,3 +87,39 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
|
|
|
87
87
|
body)) : null,
|
|
88
88
|
more);
|
|
89
89
|
}
|
|
90
|
+
|
|
91
|
+
// The banner with Accept change and the recheck wired, for the invoice page
|
|
92
|
+
// and a job's Review sidecar (jobs.js). `canAccept`: the person may change
|
|
93
|
+
// drafts (owners and admins). `unsaved()`: edits on the page that Accept
|
|
94
|
+
// would lose. `done(invoice)` hears the invoice after Accept or a recheck.
|
|
95
|
+
export function jobChangeReview(ui, change, { money, canAccept = false, unsaved = () => false, done = () => {} }) {
|
|
96
|
+
const { h, flow, toast } = ui;
|
|
97
|
+
const system = change.systemName;
|
|
98
|
+
return jobChangeBanner(ui, change, {
|
|
99
|
+
money,
|
|
100
|
+
onAccept: !canAccept ? null : async () => {
|
|
101
|
+
const lost = [change.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, unsaved() ? 'Unsaved changes are lost.' : null].filter(Boolean);
|
|
102
|
+
if (lost.length) {
|
|
103
|
+
const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
|
|
104
|
+
if (!ok) return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const res = await flow(`/api/invoicing/jobs/${change.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
|
|
108
|
+
toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
|
|
109
|
+
await done(res.invoice);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
toast(error.message, 'danger');
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
// Reads this one job from the work system again, then every check runs.
|
|
115
|
+
onRecheck: async () => {
|
|
116
|
+
try {
|
|
117
|
+
const res = await flow(`/api/invoicing/jobs/${change.jobId}/recheck`, { method: 'POST', body: '{}' });
|
|
118
|
+
toast(res.invoice?.jobChange ? `Still changed in ${system}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
|
|
119
|
+
if (res.invoice) await done(res.invoice);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
toast(error.message, 'danger');
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
package/server/ui/jobs.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
16
16
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
17
17
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
18
|
+
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
18
19
|
import {
|
|
19
20
|
caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
|
|
20
21
|
} from '/assets/ui/job-board.js';
|
|
@@ -22,10 +23,14 @@ import {
|
|
|
22
23
|
COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
|
|
23
24
|
normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
|
|
24
25
|
} from '/assets/ui/job-view.js';
|
|
26
|
+
import { sidecar } from '/assets/ui/sidecar.js';
|
|
25
27
|
import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
|
|
26
28
|
|
|
27
29
|
const LAYOUT_KEY = 'flow_jobs_layout';
|
|
28
30
|
const FOLDED_KEY = 'flow_jobs_folded';
|
|
31
|
+
// The job panel's sidecar: the tab last open, or closed.
|
|
32
|
+
const SIDE_KEY = 'flow_job_side';
|
|
33
|
+
const SIDE_TABS = ['activity', 'review', 'closed'];
|
|
29
34
|
const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
|
|
30
35
|
// A job's events (server/core/ledger.js), in plain words.
|
|
31
36
|
const ACTIVITY = {
|
|
@@ -85,6 +90,17 @@ function saveFolded(folded) {
|
|
|
85
90
|
try { localStorage.setItem(FOLDED_KEY, JSON.stringify(folded)); } catch { /* a convenience only */ }
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
function readSide() {
|
|
94
|
+
try {
|
|
95
|
+
const value = localStorage.getItem(SIDE_KEY);
|
|
96
|
+
return SIDE_TABS.includes(value) ? value : 'activity';
|
|
97
|
+
} catch { return 'activity'; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function saveSide(tab) {
|
|
101
|
+
try { localStorage.setItem(SIDE_KEY, tab ?? 'closed'); } catch { /* a convenience only */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
88
104
|
|
|
89
105
|
const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6v3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r=".9" fill="currentColor"/></svg>';
|
|
90
106
|
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>';
|
|
@@ -116,9 +132,8 @@ export async function renderJobs(main, ui) {
|
|
|
116
132
|
const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
|
|
117
133
|
// In the list, ticked rows get the same floating bar as Invoices.
|
|
118
134
|
const bulk = bulkBar(ui, { label: 'Ticked jobs' });
|
|
119
|
-
main.classList.add('fx-main--wide');
|
|
120
135
|
main.append(
|
|
121
|
-
pageHeader('Jobs'
|
|
136
|
+
pageHeader('Jobs'),
|
|
122
137
|
h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, find.element, toggle)),
|
|
123
138
|
host,
|
|
124
139
|
bulk.element);
|
|
@@ -1086,25 +1101,8 @@ export async function renderJobs(main, ui) {
|
|
|
1086
1101
|
if (!o) return [];
|
|
1087
1102
|
if (o.invoiceId) {
|
|
1088
1103
|
const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
// puts the job's new lines on Flow's own draft. Nothing goes elsewhere.
|
|
1092
|
-
if (!o.canUseNewLines) return [open];
|
|
1093
|
-
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
1094
|
-
return [h('button', {
|
|
1095
|
-
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
1096
|
-
onclick: async (event) => {
|
|
1097
|
-
event.currentTarget.disabled = true;
|
|
1098
|
-
try {
|
|
1099
|
-
await flow(`/api/invoicing/jobs/${o.id}/use-new-lines`, { method: 'POST', body: '{}' });
|
|
1100
|
-
ui.toast(`The draft invoice now matches ${system}.`, 'success');
|
|
1101
|
-
} catch (error) {
|
|
1102
|
-
ui.toast(error.message, 'danger');
|
|
1103
|
-
}
|
|
1104
|
-
close();
|
|
1105
|
-
await load();
|
|
1106
|
-
},
|
|
1107
|
-
}, `Adopt ${system} changes`), open];
|
|
1104
|
+
// A changed job is accepted in the panel's Review sidecar.
|
|
1105
|
+
return [h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label)];
|
|
1108
1106
|
}
|
|
1109
1107
|
// Flow's own stage: a person marks a job ready to invoice, or an
|
|
1110
1108
|
// automation does (Settings, Automations).
|
|
@@ -1147,15 +1145,20 @@ export async function renderJobs(main, ui) {
|
|
|
1147
1145
|
|
|
1148
1146
|
// The job's page: a large panel over the board, like a task page. A
|
|
1149
1147
|
// breadcrumb, the client's name, the stage, the job's fields, what to fix
|
|
1150
|
-
// and its lines
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1148
|
+
// and its lines. At the side (below on a phone), the sidecar: Activity,
|
|
1149
|
+
// and Review when the job changed after its draft (what changed, 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` scrolls to what
|
|
1152
|
+
// to fix, and opens Review when there is one. The job's custom fields can
|
|
1153
|
+
// be changed here (in Flow only), and its stage moved (a hand-off to the
|
|
1154
|
+
// work system).
|
|
1154
1155
|
function openJob(job, { atFix = false } = {}) {
|
|
1155
1156
|
const o = job.obligation;
|
|
1156
1157
|
const stage = invoiceStage(o);
|
|
1157
1158
|
const found = problemsOf(job);
|
|
1158
1159
|
const choose = found.find((f) => f.go === 'choose') ?? null;
|
|
1160
|
+
// The job changed after its draft: the sidecar has a Review tab.
|
|
1161
|
+
const reviewable = Boolean(o?.invoiceId) && found.some((f) => f.go === 'review');
|
|
1159
1162
|
const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
|
|
1160
1163
|
const status = job.status ?? NO_STATUS;
|
|
1161
1164
|
const late = isLate(job);
|
|
@@ -1170,9 +1173,14 @@ export async function renderJobs(main, ui) {
|
|
|
1170
1173
|
: choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
|
|
1171
1174
|
: stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
|
|
1172
1175
|
const rest = found.filter((f) => f.go !== 'choose');
|
|
1176
|
+
// One short line a fix: its label, what is wrong, and its link. What to
|
|
1177
|
+
// do is the link's tip.
|
|
1173
1178
|
const fixBox = rest.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
|
|
1174
1179
|
h('h3', {}, 'Fix'),
|
|
1175
|
-
rest.map((f) =>
|
|
1180
|
+
rest.map((f) => {
|
|
1181
|
+
const go = fixLink(f.go, f.todo);
|
|
1182
|
+
return h('div', { class: 'fx-task__fixrow', title: go ? null : f.todo }, h('strong', {}, f.label), h('span', {}, f.text), go);
|
|
1183
|
+
})) : null;
|
|
1176
1184
|
// No invoice, and the match is unclear: one line and two buttons. The
|
|
1177
1185
|
// possible matches are compared on the Reconcile page, not here.
|
|
1178
1186
|
const chooseBox = choose ? h('section', { class: 'fx-task__choose', 'aria-label': 'Invoice' },
|
|
@@ -1200,8 +1208,12 @@ export async function renderJobs(main, ui) {
|
|
|
1200
1208
|
}
|
|
1201
1209
|
// Each fix's link: Flow's own invoice, the job in the work system, or
|
|
1202
1210
|
// the Flow page where the fix is made (job-board.js GO).
|
|
1203
|
-
function fixLink(go) {
|
|
1204
|
-
const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, onclick: (event) => { close(); ui.go(event); } }, text);
|
|
1211
|
+
function fixLink(go, tip = null) {
|
|
1212
|
+
const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, title: tip, onclick: (event) => { close(); ui.go(event); } }, text);
|
|
1213
|
+
if (go === 'review' && reviewable) {
|
|
1214
|
+
return h('button', { class: 'fx-task__link fx-task__linkbtn', type: 'button', title: tip, onclick: () => side.open('review') }, 'Review');
|
|
1215
|
+
}
|
|
1216
|
+
if (go === 'review' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
|
|
1205
1217
|
if (go === 'invoice' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
|
|
1206
1218
|
if (go === 'work' && job.link?.url) {
|
|
1207
1219
|
return h('a', { class: 'fx-task__link', href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`);
|
|
@@ -1213,12 +1225,55 @@ export async function renderJobs(main, ui) {
|
|
|
1213
1225
|
}
|
|
1214
1226
|
const lines = job.lines ?? [];
|
|
1215
1227
|
const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
|
|
1228
|
+
const activityBody = h('div', { class: 'fx-task__activity' },
|
|
1229
|
+
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
|
+
// Review: the job's change as the invoice page shows it (job-change.js),
|
|
1231
|
+
// read from the invoice when the sidecar first needs it.
|
|
1232
|
+
const reviewBody = h('div', { class: 'fx-task__review' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
1233
|
+
let reviewRead = false;
|
|
1234
|
+
const readReview = () => {
|
|
1235
|
+
if (reviewRead) return;
|
|
1236
|
+
reviewRead = true;
|
|
1237
|
+
flow(`/api/invoicing/invoices/${o.invoiceId}`).then(({ invoice }) => drawReview(invoice))
|
|
1238
|
+
.catch(() => reviewBody.replaceChildren(h('p', {}, 'Flow could not read the change.')));
|
|
1239
|
+
};
|
|
1240
|
+
function drawReview(invoice) {
|
|
1241
|
+
const change = invoice?.jobChange ?? null;
|
|
1242
|
+
reviewBody.replaceChildren(change
|
|
1243
|
+
? jobChangeReview(ui, change, { money: formatCents, canAccept: state.canDraft, done: reopen })
|
|
1244
|
+
: h('p', { class: 'fx-dim' }, `The draft matches ${system}.`));
|
|
1245
|
+
}
|
|
1246
|
+
// After Accept change or a recheck: the board reads again, and this
|
|
1247
|
+
// job's panel opens again with its new facts.
|
|
1248
|
+
async function reopen() {
|
|
1249
|
+
await load();
|
|
1250
|
+
const fresh = state.jobs.find((j) => jobKey(j) === jobKey(job));
|
|
1251
|
+
close();
|
|
1252
|
+
if (fresh) openJob(fresh);
|
|
1253
|
+
}
|
|
1254
|
+
const sideButton = h('button', {
|
|
1255
|
+
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() === 'review' && reviewable ? 'review' : 'activity')),
|
|
1257
|
+
}, lucide(h, 'panel-right'));
|
|
1258
|
+
const side = sidecar(h, {
|
|
1259
|
+
label: 'Job side panel',
|
|
1260
|
+
tabs: [
|
|
1261
|
+
{ id: 'activity', label: 'Activity', icon: 'history', render: () => activityBody },
|
|
1262
|
+
reviewable ? { id: 'review', label: 'Review', icon: 'git-compare', mark: true, render: () => { readReview(); return reviewBody; } } : null,
|
|
1263
|
+
].filter(Boolean),
|
|
1264
|
+
onChange: (tab) => {
|
|
1265
|
+
saveSide(tab);
|
|
1266
|
+
sideButton.setAttribute('aria-pressed', String(Boolean(tab)));
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
side.element.classList.add('fx-task__side');
|
|
1216
1270
|
element.append(h('div', { class: 'fx-task__grid' },
|
|
1217
1271
|
h('div', { class: 'fx-task__main' },
|
|
1218
1272
|
h('header', { class: 'fx-task__head' },
|
|
1219
1273
|
h('nav', { class: 'fx-task__crumb', 'aria-label': 'Breadcrumb' },
|
|
1220
1274
|
h('span', {}, 'Jobs'), h('span', { class: 'fx-task__slash', 'aria-hidden': 'true' }, '/'), h('span', {}, status)),
|
|
1221
|
-
h('
|
|
1275
|
+
h('div', { class: 'fx-task__tools' }, sideButton,
|
|
1276
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: close }, '✕'))),
|
|
1222
1277
|
h('h2', { class: 'fx-task__title' }, clientOf(job)),
|
|
1223
1278
|
h('div', { class: 'fx-task__stage' }, stagePill(status)),
|
|
1224
1279
|
h('dl', { class: 'fx-task__fields' },
|
|
@@ -1255,13 +1310,14 @@ export async function renderJobs(main, ui) {
|
|
|
1255
1310
|
h('td', { class: 'fx-num' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))))
|
|
1256
1311
|
: h('p', { class: 'fx-dim' }, 'No lines.')),
|
|
1257
1312
|
h('div', { class: 'fx-task__actions' }, actions(job, close))),
|
|
1258
|
-
|
|
1259
|
-
h('h3', {}, 'Activity'),
|
|
1260
|
-
o ? activity : h('p', { class: 'fx-dim' }, `Flow has no invoice record for this job yet. It makes one when ${system} reports the job complete.`))));
|
|
1313
|
+
side.element));
|
|
1261
1314
|
element.addEventListener('click', (event) => { if (event.target === element) close(); });
|
|
1262
1315
|
element.addEventListener('close', () => element.remove());
|
|
1263
1316
|
document.body.append(element);
|
|
1264
1317
|
element.showModal();
|
|
1318
|
+
const last = readSide();
|
|
1319
|
+
if (atFix && reviewable) side.open('review');
|
|
1320
|
+
else if (last !== 'closed') side.open(last === 'review' && reviewable ? 'review' : 'activity');
|
|
1265
1321
|
if (atFix && (chooseBox ?? fixBox)) (chooseBox ?? fixBox).scrollIntoView({ block: 'start' });
|
|
1266
1322
|
if (!o) return;
|
|
1267
1323
|
const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
|
package/server/ui/monitoring.js
CHANGED
|
@@ -99,6 +99,7 @@ export async function startMonitoring({ flow, person }) {
|
|
|
99
99
|
integrations,
|
|
100
100
|
tracesSampleRate,
|
|
101
101
|
beforeSendSpan: scrubSpan,
|
|
102
|
+
initialScope: { tags: config.tags ?? {} },
|
|
102
103
|
});
|
|
103
104
|
Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
|
|
104
105
|
// Who reported or met the error, so the lab can follow up.
|
package/server/ui/price-list.js
CHANGED
|
@@ -29,7 +29,7 @@ export async function renderPriceList(main, ui) {
|
|
|
29
29
|
const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Add code');
|
|
30
30
|
const reconcileButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', hidden: true, onclick: () => ui.go('/price-list/reconcile') }, 'Reconcile');
|
|
31
31
|
const alerts = alertStrip(ui, 'price-list');
|
|
32
|
-
main.append(pageHeader('Price list', {
|
|
32
|
+
main.append(pageHeader('Price list', { help: { href: DOCS, label: 'How the price list works' }, actions: [reconcileButton, addButton] }), alerts.element, host);
|
|
33
33
|
|
|
34
34
|
let data = null;
|
|
35
35
|
let query = '';
|
|
@@ -176,8 +176,7 @@ export async function renderPriceList(main, ui) {
|
|
|
176
176
|
table,
|
|
177
177
|
];
|
|
178
178
|
const settings = h('section', { class: 'fx-set fx-price__settings' }, h('h2', { class: 'fx-set__title' }, 'Invoice lines'), h('div', { class: 'fx-skeleton' }));
|
|
179
|
-
linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r
|
|
180
|
-
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How the price list works'))));
|
|
179
|
+
linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r));
|
|
181
180
|
sections.push(settings);
|
|
182
181
|
host.replaceChildren(...sections.filter(Boolean));
|
|
183
182
|
// Opened from Reconcile's "Edit code": that code's dialog, once.
|
|
@@ -26,17 +26,16 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
26
26
|
const icon = (name, label) => lucide(h, name, label);
|
|
27
27
|
let view = new URLSearchParams(location.search).get('view') === 'list' ? 'list' : 'card';
|
|
28
28
|
const host = h('div', { class: 'fx-xi fx-prc' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
29
|
-
const
|
|
29
|
+
const tally = h('span', {});
|
|
30
30
|
const toggle = (name, label, glyph) => h('button', {
|
|
31
31
|
class: 'fx-xi__view', type: 'button', role: 'tab', 'aria-selected': String(view === name), 'aria-label': label, title: label, 'data-view': name,
|
|
32
32
|
onclick: () => setView(name),
|
|
33
33
|
}, icon(glyph));
|
|
34
34
|
const bulk = bulkBar(ui, { label: 'Ticked records' });
|
|
35
35
|
main.append(
|
|
36
|
-
|
|
37
|
-
h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, 'Reconcile')),
|
|
36
|
+
ui.crumbs(['/price-list', 'Price list'], 'Reconcile'),
|
|
38
37
|
ui.pageHeader('Reconcile prices', {
|
|
39
|
-
|
|
38
|
+
count: tally,
|
|
40
39
|
actions: [h('div', { class: 'fx-xi__views', role: 'tablist', 'aria-label': 'View' }, toggle('card', 'One at a time', 'square'), toggle('list', 'List, tick many', 'list-checks'))],
|
|
41
40
|
}),
|
|
42
41
|
host, bulk.element);
|
|
@@ -129,13 +128,13 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
129
128
|
if (!data) return;
|
|
130
129
|
bulk.draw({ count: 0 });
|
|
131
130
|
if (!data.systems.length) {
|
|
132
|
-
|
|
131
|
+
tally.replaceChildren('');
|
|
133
132
|
host.replaceChildren(h('p', { class: 'fx-empty' }, 'No integration with a price list is on. Flow uses its own list alone.'),
|
|
134
133
|
h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, '← Price list'));
|
|
135
134
|
return;
|
|
136
135
|
}
|
|
137
136
|
if (!rows.length) {
|
|
138
|
-
|
|
137
|
+
tally.replaceChildren('');
|
|
139
138
|
host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Every record has a code, and every price agrees.'),
|
|
140
139
|
h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list')));
|
|
141
140
|
return;
|
|
@@ -184,7 +183,7 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
184
183
|
// One record (or one differing code) at a time.
|
|
185
184
|
function drawCard() {
|
|
186
185
|
const row = rows[at];
|
|
187
|
-
|
|
186
|
+
tally.replaceChildren(`${rows.length} to check`);
|
|
188
187
|
const next = () => { at = (at + 1) % rows.length; pick = 0; chosen = null; drawCard(); };
|
|
189
188
|
const record = row.record;
|
|
190
189
|
const item = row.kind === 'differs' ? row.item : chosen ?? row.candidates[pick]?.item ?? null;
|
|
@@ -267,7 +266,7 @@ export async function renderPriceReconcile(main, ui) {
|
|
|
267
266
|
|
|
268
267
|
// Every record and code to check; ticked rows go from the bulk bar.
|
|
269
268
|
function drawList() {
|
|
270
|
-
|
|
269
|
+
tally.replaceChildren(`${rows.length} to check`);
|
|
271
270
|
const ticked = new Set();
|
|
272
271
|
const boxes = new Map();
|
|
273
272
|
const picked = () => rows.filter((r) => ticked.has(r.key));
|
package/server/ui/routes.js
CHANGED
|
@@ -21,7 +21,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
21
21
|
const logoDir = join(here, 'logos');
|
|
22
22
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
23
23
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
24
|
-
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'flow-mark.svg']);
|
|
24
|
+
const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'flow-mark.svg']);
|
|
25
25
|
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
26
26
|
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
27
27
|
const MAX_AGE = '1h';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The sidecar, as in ClickUp: a panel at the right of a page or a dialog.
|
|
2
|
+
// The Custom fields manager opens it over its table (`overlay`); a job's
|
|
3
|
+
// panel keeps it as its right column, with tabs (Activity, Review). Closed,
|
|
4
|
+
// it takes no room, so the main area gets the full width.
|
|
5
|
+
//
|
|
6
|
+
// sidecar(h, { label, overlay, escape, tabs, onChange }) answers
|
|
7
|
+
// { element, isOpen, open, show, close, tab, setTabs }:
|
|
8
|
+
// open(id) selects tab `id` (or the first) and draws its `render()`
|
|
9
|
+
// show(node) draws one node with no tabs (the overlay's own form)
|
|
10
|
+
// close() hides it and empties it
|
|
11
|
+
// `tabs` is [{ id, label, icon, render }]; each tab is an icon and one word.
|
|
12
|
+
// `escape` closes it on Esc when no dialog or menu is open over the page.
|
|
13
|
+
// `onChange(id)` hears each open (the tab id) and close (null).
|
|
14
|
+
|
|
15
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
16
|
+
|
|
17
|
+
export function sidecar(h, { label, overlay = false, escape = false, tabs = [], onChange = null } = {}) {
|
|
18
|
+
const element = h('aside', { class: `fx-sidecar${overlay ? ' fx-sidecar--over' : ''}`, 'aria-label': label, hidden: true });
|
|
19
|
+
let list = tabs;
|
|
20
|
+
let current = null;
|
|
21
|
+
|
|
22
|
+
const closeButton = () => h('button', { class: 'fx-sidecar__close', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: () => api.close() }, lucide(h, 'x'));
|
|
23
|
+
|
|
24
|
+
function drawTabs() {
|
|
25
|
+
const head = h('header', { class: 'fx-sidecar__head' },
|
|
26
|
+
h('div', { class: 'fx-sidecar__tabs', role: 'tablist', 'aria-label': label },
|
|
27
|
+
list.map((t) => h('button', {
|
|
28
|
+
class: 'fx-sidecar__tab', type: 'button', role: 'tab', 'data-tab': t.id,
|
|
29
|
+
'aria-selected': String(t.id === current), onclick: () => api.open(t.id),
|
|
30
|
+
}, lucide(h, t.icon), h('span', {}, t.label), t.mark ? h('span', { class: 'fx-sidecar__mark', 'aria-hidden': 'true' }) : null))),
|
|
31
|
+
closeButton());
|
|
32
|
+
const tab = list.find((t) => t.id === current);
|
|
33
|
+
element.replaceChildren(head, h('div', { class: 'fx-sidecar__body', role: 'tabpanel' }, tab?.render?.() ?? null));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const api = {
|
|
37
|
+
element,
|
|
38
|
+
isOpen: () => !element.hidden,
|
|
39
|
+
tab: () => (element.hidden ? null : current),
|
|
40
|
+
open(id = null) {
|
|
41
|
+
current = list.find((t) => t.id === id)?.id ?? list[0]?.id ?? null;
|
|
42
|
+
drawTabs();
|
|
43
|
+
element.hidden = false;
|
|
44
|
+
onChange?.(current);
|
|
45
|
+
},
|
|
46
|
+
show(node) {
|
|
47
|
+
current = null;
|
|
48
|
+
element.replaceChildren(node);
|
|
49
|
+
element.hidden = false;
|
|
50
|
+
},
|
|
51
|
+
close() {
|
|
52
|
+
if (element.hidden) return;
|
|
53
|
+
element.hidden = true;
|
|
54
|
+
element.replaceChildren();
|
|
55
|
+
current = null;
|
|
56
|
+
onChange?.(null);
|
|
57
|
+
},
|
|
58
|
+
// New tabs (a tab's data came in): an open tab draws again.
|
|
59
|
+
setTabs(next) {
|
|
60
|
+
list = next;
|
|
61
|
+
if (!element.hidden && current) drawTabs();
|
|
62
|
+
},
|
|
63
|
+
closeButton,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (escape) {
|
|
67
|
+
document.addEventListener('keydown', (event) => {
|
|
68
|
+
if (event.key === 'Escape' && element.isConnected && !element.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) api.close();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return api;
|
|
72
|
+
}
|