@arcforgelabs/flow 2026.11.1 → 2026.11.3
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/SECURITY.md +11 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +78 -12
- package/server/auth/routes.js +20 -0
- package/server/auth/session.js +38 -1
- package/server/auth/settings.js +16 -0
- package/server/core/invoice-rules.js +25 -10
- package/server/core/job-board.js +12 -4
- package/server/core/job-match.js +6 -4
- package/server/core/ledger.js +84 -13
- package/server/core/line-mapping.js +7 -3
- package/server/core/pipeline.js +6 -2
- package/server/core/sending.js +31 -9
- package/server/integrations/seazona/index.js +6 -1
- package/server/integrations/seazona/routes.js +65 -2
- package/server/integrations/seazona/settings.js +3 -0
- package/server/integrations/xero/ledger.js +5 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +9 -6
- package/server/ui/flow.css +75 -4
- package/server/ui/home.js +120 -0
- package/server/ui/invoice-rules.js +24 -3
- package/server/ui/invoicing.js +50 -7
- package/server/ui/job-board.js +20 -10
- package/server/ui/job-change.js +63 -15
- package/server/ui/jobs.js +4 -4
- package/server/ui/nav-pins.js +5 -3
- package/server/ui/reconcile.js +4 -2
- package/server/ui/routes.js +1 -1
- package/skills/flow/SKILL.md +237 -0
- package/skills/flow/flow.sh +90 -0
|
@@ -47,6 +47,9 @@ export function ruleSummary(rule, meta) {
|
|
|
47
47
|
export function rulesSection(ui, title = 'Rules') {
|
|
48
48
|
const { h, flow, toast, notice, dialog, me, settingRow, iconPicker } = ui;
|
|
49
49
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
50
|
+
// A Pending invoice opens the exact rule responsible, using this same
|
|
51
|
+
// conditions/editor UI. Read-only users see the rule, never write controls.
|
|
52
|
+
const targetRule = new URLSearchParams(location.search).get('rule');
|
|
50
53
|
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
|
|
51
54
|
let meta;
|
|
52
55
|
let rules = [];
|
|
@@ -177,13 +180,25 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
177
180
|
if (editors.length === 1) return toast('A rule needs at least one condition.', 'danger');
|
|
178
181
|
editors = editors.filter((e) => e.element !== element);
|
|
179
182
|
element.remove();
|
|
183
|
+
validateAction();
|
|
180
184
|
return null;
|
|
181
185
|
});
|
|
182
186
|
editors.push(editor);
|
|
183
187
|
list.append(editor.element);
|
|
188
|
+
validateAction();
|
|
184
189
|
return null;
|
|
185
190
|
};
|
|
191
|
+
// Draft-only fields cannot hold work before a draft exists. Native form
|
|
192
|
+
// validation keeps this editor and its choices open instead of a refused PUT.
|
|
193
|
+
const validateAction = () => {
|
|
194
|
+
const field = editors.map((e) => meta.fields.find((f) => f.key === e.read().field)).find((f) => f?.draftOnly);
|
|
195
|
+
action.setCustomValidity(action.value !== 'pending' && field
|
|
196
|
+
? `${field.label} checks require Pending, with a note. Choose Pending or remove that condition.` : '');
|
|
197
|
+
};
|
|
198
|
+
list.addEventListener('change', validateAction);
|
|
199
|
+
action.addEventListener('change', validateAction);
|
|
186
200
|
for (const c of draft?.conditions ?? [null]) addCondition(c);
|
|
201
|
+
validateAction();
|
|
187
202
|
const more = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Add condition');
|
|
188
203
|
more.addEventListener('click', () => addCondition());
|
|
189
204
|
|
|
@@ -242,16 +257,22 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
242
257
|
open.addEventListener('click', () => edit(rule));
|
|
243
258
|
controls.unshift(open);
|
|
244
259
|
}
|
|
245
|
-
|
|
260
|
+
const element = row(rule.name, ruleSummary(rule, meta), controls);
|
|
261
|
+
element.setAttribute('role', 'region');
|
|
262
|
+
element.setAttribute('aria-label', `Rule ${rule.name}`);
|
|
263
|
+
return element;
|
|
246
264
|
};
|
|
247
265
|
|
|
248
266
|
function draw() {
|
|
249
|
-
const
|
|
267
|
+
const shown = targetRule ? rules.filter((rule) => rule.id === targetRule) : rules;
|
|
268
|
+
const add = disabled || targetRule ? null : h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Add rule');
|
|
250
269
|
add?.addEventListener('click', () => edit(null));
|
|
251
270
|
section.replaceChildren(
|
|
252
271
|
h('h2', { class: 'fx-set__title' }, title),
|
|
253
272
|
h('p', { class: 'fx-set__hint', style: { margin: '0 0 4px' } }, 'When a rule matches, a ready job waits, is never drafted, or its draft is Pending with the rule\'s note.'),
|
|
254
|
-
...(
|
|
273
|
+
...(shown.length ? shown.map(rowOf) : [targetRule
|
|
274
|
+
? notice('This rule is no longer available. Reopen the invoice to check its remaining problems.', 'danger')
|
|
275
|
+
: h('p', { class: 'fx-dim' }, 'No rules. Every ready job is drafted.')]),
|
|
255
276
|
h('div', { class: 'fx-rule__footer' }, add,
|
|
256
277
|
h('a', { class: 'fx-dim', href: DOCS, target: '_blank', rel: 'noopener' }, 'How rules work')));
|
|
257
278
|
}
|
package/server/ui/invoicing.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
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
|
-
import { caseNumber } from '/assets/ui/job-board.js';
|
|
11
|
+
import { caseNumber, fixes } from '/assets/ui/job-board.js';
|
|
12
12
|
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
13
13
|
import { cardRows, rowMenu } from '/assets/ui/kit.js';
|
|
14
14
|
import { isPhone, WIDTHS } from '/assets/ui/mobile.js';
|
|
@@ -111,7 +111,7 @@ function toCents(text) {
|
|
|
111
111
|
|
|
112
112
|
const fromCents = (cents) => (cents == null ? '' : (cents / 100).toFixed(2));
|
|
113
113
|
|
|
114
|
-
function localDate(date = new Date()) {
|
|
114
|
+
export function localDate(date = new Date()) {
|
|
115
115
|
const pad = (n) => String(n).padStart(2, '0');
|
|
116
116
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
117
117
|
}
|
|
@@ -146,6 +146,13 @@ function readFilters() {
|
|
|
146
146
|
|
|
147
147
|
export async function renderInvoiceList(main, ui) {
|
|
148
148
|
const { h, flow, listHead, listFrame, notice, go, toast } = ui;
|
|
149
|
+
// A link may name the tab (/invoices?tab=draft, from Home). It is kept as
|
|
150
|
+
// the open tab, and the address goes back to /invoices.
|
|
151
|
+
const linked = new URLSearchParams(globalThis.location?.search ?? '').get('tab');
|
|
152
|
+
if (linked) {
|
|
153
|
+
sessionStorage.setItem('flow_invoice_tab', linked);
|
|
154
|
+
history.replaceState(null, '', location.pathname);
|
|
155
|
+
}
|
|
149
156
|
const saved = OLD_TAB[sessionStorage.getItem('flow_invoice_tab')] ?? sessionStorage.getItem('flow_invoice_tab');
|
|
150
157
|
// Back from an invoice comes here with the tab, search and scroll it left.
|
|
151
158
|
const back = readNav();
|
|
@@ -981,6 +988,20 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
981
988
|
// item. A check on something this page cannot change (the job's
|
|
982
989
|
// practitioner) marks nothing; the banner says it.
|
|
983
990
|
const problemList = invoice?.problems ?? [];
|
|
991
|
+
// Dispatch intents to the real editor or shared job comparison, not a
|
|
992
|
+
// parallel warning-code list. Missing data/unknown intents stay blocked.
|
|
993
|
+
// Adapter warnings retain their existing owner-specific actions.
|
|
994
|
+
const hasRuleReview = (p) => p.resolution?.kind === 'rule_review' && Boolean(p.rule);
|
|
995
|
+
const hasJobHold = (p) => p.resolution?.kind === 'job_hold' && Boolean(invoice?.job?.id)
|
|
996
|
+
&& typeof p.resolution.reason === 'string' && Boolean(p.resolution.reason.trim());
|
|
997
|
+
const hasJobReview = (p) => p.resolution?.kind === 'job_review' && invoice?.jobChange
|
|
998
|
+
&& Boolean(p.sourceBilling) === Boolean(invoice.jobChange.sourceReview);
|
|
999
|
+
// The source check runs first. Its Recheck reveals the ordinary job
|
|
1000
|
+
// comparison once the source problem clears; that view is not missing.
|
|
1001
|
+
const deferredJobReview = (p) => p.resolution?.kind === 'job_review'
|
|
1002
|
+
&& !p.sourceBilling && Boolean(invoice?.jobChange?.sourceReview);
|
|
1003
|
+
const unavailable = problemList.filter((p) => p.resolution
|
|
1004
|
+
&& p.resolution.kind !== 'invoice_edit' && !hasJobReview(p) && !deferredJobReview(p) && !hasRuleReview(p) && !hasJobHold(p));
|
|
984
1005
|
const failed = new Set(problemList.flatMap((p) => p.fields ?? []));
|
|
985
1006
|
const badLines = new Set(problemList.filter((p) => p.fields?.includes('line_item')).map((p) => p.line));
|
|
986
1007
|
const boxes = { reference };
|
|
@@ -1146,7 +1167,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1146
1167
|
// it and queues it, the same as Send all ready. With none, Flow issues it
|
|
1147
1168
|
// and keeps it. A Pending draft has no send: fix its problem first.
|
|
1148
1169
|
const to = invoice?.sendsTo ?? context.sendsTo ?? null;
|
|
1149
|
-
const ready = invoice?.view === 'ready';
|
|
1170
|
+
const ready = invoice?.view === 'ready' && !problemList.length;
|
|
1150
1171
|
// A lab's rule keeps a non-chargeable draft in Flow: nothing sends or approves it.
|
|
1151
1172
|
const nonChargeable = invoice?.view === 'non_chargeable';
|
|
1152
1173
|
const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
|
|
@@ -1210,8 +1231,8 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1210
1231
|
const syncIssue = () => {
|
|
1211
1232
|
if (!issueButton) return;
|
|
1212
1233
|
const missing = [contact ? null : 'a client', dueDate.value ? null : 'a due date'].filter(Boolean);
|
|
1213
|
-
issueButton.disabled = missing.length > 0 || Boolean(invoice?.jobChange);
|
|
1214
|
-
issueButton.title = invoice?.jobChange ? 'Fix the problem first.' : missing.length ? `Choose ${missing.join(' and ')} first.` : 'Gives it the next number and locks it. Nothing is sent.';
|
|
1234
|
+
issueButton.disabled = missing.length > 0 || Boolean(invoice?.jobChange) || problemList.length > 0;
|
|
1235
|
+
issueButton.title = invoice?.jobChange || problemList.length ? 'Fix the problem first.' : missing.length ? `Choose ${missing.join(' and ')} first.` : 'Gives it the next number and locks it. Nothing is sent.';
|
|
1215
1236
|
};
|
|
1216
1237
|
onContact = syncIssue;
|
|
1217
1238
|
clientInput.addEventListener('input', syncIssue);
|
|
@@ -1236,7 +1257,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1236
1257
|
const jobChange = invoice?.jobChange ?? null;
|
|
1237
1258
|
// The job changed after the invoice was made: its own banner shows what
|
|
1238
1259
|
// changed. Other problems stay one line.
|
|
1239
|
-
const otherProblems =
|
|
1260
|
+
const otherProblems = problemList.filter((p) => !hasJobReview(p));
|
|
1240
1261
|
const offer = otherProblems.find((p) => ['xero_contact_missing', 'xero_contact_ambiguous'].includes(p.code) && (p.offerLink || p.offerCreate));
|
|
1241
1262
|
// A phone shows each problem's short name ("Client not in Xero") on one line.
|
|
1242
1263
|
const shorts = otherProblems.every((p) => p.short) ? [...new Set(otherProblems.map((p) => p.short))].join(', ') : null;
|
|
@@ -1251,10 +1272,32 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1251
1272
|
done: async () => redraw((await flow(`/api/invoicing/invoices/${invoice.id}`)).invoice),
|
|
1252
1273
|
})
|
|
1253
1274
|
: [];
|
|
1275
|
+
// Every matching rule links to its own conditions and existing controls,
|
|
1276
|
+
// including custom rules. The target enforces read-only roles itself.
|
|
1277
|
+
const ruleActions = [...new Map(problemList.filter(hasRuleReview).map((p) => [p.rule, p])).values()]
|
|
1278
|
+
.map((p) => h('a', { class: 'fx-btn fx-btn--sm',
|
|
1279
|
+
href: `/invoicing-settings/invoices?rule=${encodeURIComponent(p.rule)}`,
|
|
1280
|
+
onclick: (event) => go(event),
|
|
1281
|
+
}, `Review ${p.short ?? 'rule'}`));
|
|
1282
|
+
// Non-change holds use the Jobs workflow's existing instructions, not a
|
|
1283
|
+
// comparison that does not exist. Reading them never releases the hold.
|
|
1284
|
+
const holdSteps = fixes({ view: 'review_required', holdState: 'review',
|
|
1285
|
+
reasons: [...new Set(problemList.filter(hasJobHold).map((p) => p.resolution.reason))] });
|
|
1286
|
+
const holdReview = holdSteps.length ? h('section', { 'aria-label': 'Job hold review' },
|
|
1287
|
+
...holdSteps.map((step) => h('p', {}, step.todo)),
|
|
1288
|
+
invoice.job.link?.url ? h('a', { class: 'fx-btn fx-btn--sm', href: invoice.job.link.url, target: '_blank', rel: 'noopener' },
|
|
1289
|
+
`Open in ${invoice.job.systemName ?? 'work system'}`)
|
|
1290
|
+
: h('p', {}, `The source link is unavailable. Check job ${invoice.job.sourceJobRef ?? ''} in the work system, or ask an administrator to restore its link.`),
|
|
1291
|
+
h('p', {}, deleteButton
|
|
1292
|
+
? 'If this unissued draft is no longer needed, use Delete. It remains Pending while the job is in review.'
|
|
1293
|
+
: 'Ask an owner or administrator to review this draft. It remains Pending while the job is in review.')) : null;
|
|
1254
1294
|
const problems = [
|
|
1295
|
+
holdReview,
|
|
1255
1296
|
// Accepting changes the draft for the business: owners and admins only.
|
|
1256
1297
|
jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
|
|
1257
|
-
|
|
1298
|
+
problemList.some(deferredJobReview) ? notice('The job also changed after this invoice was made. Resolve the source amount problem first. Select Recheck, then review the remaining job changes.', 'danger') : null,
|
|
1299
|
+
unavailable.length ? notice('The resolution view is unavailable. Ask an administrator to restore it, then reopen this invoice. It remains Pending.', 'danger', 'Cannot resolve.') : null,
|
|
1300
|
+
problemNotice ? (clientActions.length || ruleActions.length ? h('div', { class: 'fx-offer' }, problemNotice, ...clientActions, ...ruleActions) : problemNotice) : null,
|
|
1258
1301
|
];
|
|
1259
1302
|
// A red field names the banner as its reason (for a screen reader).
|
|
1260
1303
|
const banner = problemNotice;
|
package/server/ui/job-board.js
CHANGED
|
@@ -81,7 +81,7 @@ const sentence = (text) => capital(String(text).trim().replace(/\.?$/, '.'));
|
|
|
81
81
|
// wrong, what to do, and where to go to do it (`go`, see GO). A job's fix
|
|
82
82
|
// speaks of Flow's own invoice only. How that invoice stands in an
|
|
83
83
|
// accounting system is the invoice's business, on the invoice page.
|
|
84
|
-
function reasonFix(reason) {
|
|
84
|
+
function reasonFix(reason, o = {}) {
|
|
85
85
|
const fix = (label, todo, go = null) => ({ label, text: sentence(reason), todo, go });
|
|
86
86
|
if (reason === 'the accounting system was not checked') {
|
|
87
87
|
return { label: 'Not checked yet', text: 'Flow did not check for an invoice for this job yet.', todo: 'Wait for the next check, a few minutes. If it stays, check Settings, Integrations.', go: 'integrations' };
|
|
@@ -111,7 +111,12 @@ function reasonFix(reason) {
|
|
|
111
111
|
}
|
|
112
112
|
if (/^invoice .* was (voided|deleted)$/.test(reason)) {
|
|
113
113
|
const voided = /voided$/.test(reason);
|
|
114
|
-
|
|
114
|
+
const label = `Invoice ${voided ? 'voided' : 'deleted'}`;
|
|
115
|
+
const text = `The invoice for this job was ${voided ? 'voided' : 'deleted'}.`;
|
|
116
|
+
// No Flow invoice: the voided one is in the accounting system only, so
|
|
117
|
+
// the job's own buttons make its new invoice (jobs.js).
|
|
118
|
+
if (!o.invoiceId) return { label, text, todo: null, go: 'choose' };
|
|
119
|
+
return { label, text, todo: 'Make a new invoice, or close the job.', go: 'invoice' };
|
|
115
120
|
}
|
|
116
121
|
if (/^the accounting system reports /.test(reason)) {
|
|
117
122
|
return { label: 'May be invoiced', text: 'Flow found an invoice that may be for this job.', todo: 'Check the invoice number on the job in the work system.', go: 'work' };
|
|
@@ -126,7 +131,16 @@ function reasonFix(reason) {
|
|
|
126
131
|
// system's) as a job fix. Flow's own invoice problems keep their words. A
|
|
127
132
|
// problem an accounting system found stays on the invoice page; the job only
|
|
128
133
|
// says that its invoice needs a fix.
|
|
134
|
+
function ruleFix(problem) {
|
|
135
|
+
const supported = problem.resolution?.kind === 'rule_review' && Boolean(problem.rule);
|
|
136
|
+
return { label: problem.short ?? 'Stopped by a rule', text: sentence(problem.message),
|
|
137
|
+
todo: supported ? 'Review this rule and the job facts, then reopen the job to check again.' : 'The rule resolution is unavailable. Reopen this job after an administrator restores it.',
|
|
138
|
+
go: supported ? 'rule' : null, rule: problem.rule ?? null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
129
142
|
function draftFix(problem) {
|
|
143
|
+
if (problem.resolution?.kind === 'rule_review') return ruleFix(problem);
|
|
130
144
|
const code = problem.code ?? '';
|
|
131
145
|
const fix = (label, todo) => ({ label, text: sentence(problem.message ?? label), todo, go: 'invoice' });
|
|
132
146
|
if (code === 'no_lines') return fix('No lines', 'Open the invoice and add the lines.');
|
|
@@ -141,7 +155,7 @@ function draftFix(problem) {
|
|
|
141
155
|
// Where a fix's link goes: Flow's invoice, the job in the work system, a
|
|
142
156
|
// Flow settings page, `review`: the job's Review sidecar, or `choose`: Make
|
|
143
157
|
// invoice and Link invoice buttons (jobs.js draws them).
|
|
144
|
-
export const GO = ['invoice', 'work', 'price-list', '
|
|
158
|
+
export const GO = ['invoice', 'work', 'price-list', 'rule', 'integrations', 'review', 'choose'];
|
|
145
159
|
|
|
146
160
|
// What a person must fix on a job, in order: [{ label, text, todo, go }];
|
|
147
161
|
// `todo` is null when the buttons are the instruction.
|
|
@@ -151,12 +165,8 @@ export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integ
|
|
|
151
165
|
export function fixes(o) {
|
|
152
166
|
if (invoiceStage(o) !== 'problem') return [];
|
|
153
167
|
const found = [];
|
|
154
|
-
if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
|
|
155
|
-
|
|
156
|
-
found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
|
|
157
|
-
label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Rules.', go: 'invoicing-settings',
|
|
158
|
-
})));
|
|
159
|
-
}
|
|
168
|
+
if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r, o)));
|
|
169
|
+
found.push(...(o.waitingRules ?? []).map(ruleFix));
|
|
160
170
|
if (o.invoiceStatus === 'void') {
|
|
161
171
|
found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
|
|
162
172
|
}
|
|
@@ -164,7 +174,7 @@ export function fixes(o) {
|
|
|
164
174
|
found.push(...(o.invoiceProblems ?? []).filter((p) => !(held && p.code === 'job_review')).map((p) => draftFix(p)));
|
|
165
175
|
const seen = new Set();
|
|
166
176
|
return found.filter((f) => {
|
|
167
|
-
const key = `${f.label}\u0000${f.text}`;
|
|
177
|
+
const key = `${f.rule ?? ''}\u0000${f.label}\u0000${f.text}`;
|
|
168
178
|
if (seen.has(key)) return false;
|
|
169
179
|
seen.add(key);
|
|
170
180
|
return true;
|
package/server/ui/job-change.js
CHANGED
|
@@ -16,6 +16,16 @@ const lineFull = (line, money) => `${lineText(line)} · ${line.quantity} × ${li
|
|
|
16
16
|
// [{ field, was, now }] in plain words. `money` formats cents.
|
|
17
17
|
export function changeRows(change, money) {
|
|
18
18
|
const rows = [];
|
|
19
|
+
if (change.sourceReview) {
|
|
20
|
+
const { sourceTotals, reconstructed } = change.sourceReview;
|
|
21
|
+
return [['Subtotal', 'subtotalCents'], ['GST', 'taxCents'], ['Total', 'totalCents']].map(([field, key]) => {
|
|
22
|
+
const source = sourceTotals?.[key];
|
|
23
|
+
const flow = reconstructed[key];
|
|
24
|
+
const delta = source == null || flow == null ? null : flow - source;
|
|
25
|
+
return { field, was: source == null ? 'Not supplied' : money(source), now: flow == null ? 'Unknown' : money(flow),
|
|
26
|
+
difference: delta == null ? 'Unknown' : delta === 0 ? 'Same' : `${delta > 0 ? '+' : '−'}${money(Math.abs(delta))}`, num: true };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
19
29
|
if (change.contact) rows.push({ field: 'Client', was: change.contact.was ?? '—', now: change.contact.now ?? '—' });
|
|
20
30
|
for (const line of change.lines ?? []) {
|
|
21
31
|
const at = `Line ${line.position}`;
|
|
@@ -36,6 +46,7 @@ export function changeRows(change, money) {
|
|
|
36
46
|
// The sentence after "Problem." One fact per sentence.
|
|
37
47
|
export function changeSummary(change, rowCount) {
|
|
38
48
|
const system = change.systemName ?? 'the work system';
|
|
49
|
+
if (change.sourceReview) return 'Check the source invoice amounts.';
|
|
39
50
|
const count = `${rowCount} ${rowCount === 1 ? 'change' : 'changes'}.`;
|
|
40
51
|
if (change.canAdopt) return `Changed in ${system}. ${count}`;
|
|
41
52
|
if (change.sent) return `Changed in ${system} after sending. ${count}`;
|
|
@@ -48,44 +59,73 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
|
|
|
48
59
|
const { h } = ui;
|
|
49
60
|
const system = change.systemName ?? 'the work system';
|
|
50
61
|
const rows = changeRows(change, money);
|
|
51
|
-
const
|
|
62
|
+
const source = change.sourceReview;
|
|
63
|
+
const order = change.sourceInvoiceRef ? `${source ? 'Invoice' : 'Order'} #${change.sourceInvoiceRef}` : null;
|
|
52
64
|
const link = change.link
|
|
53
65
|
? h('a', {
|
|
54
66
|
class: 'fx-btn fx-btn--sm', href: change.link.url, target: '_blank', rel: 'noopener noreferrer',
|
|
55
67
|
title: change.link.exact ? `Opens this job in ${system}.` : `Opens ${system}. Find ${order ?? 'the job'} there.`,
|
|
56
|
-
}, `Open in ${system} ↗`)
|
|
68
|
+
}, source ? (change.link.exact ? `Open job in ${system} ↗` : `Open ${system} ↗`) : `Open in ${system} ↗`)
|
|
57
69
|
: null;
|
|
58
70
|
const accept = change.canAccept && onAccept
|
|
59
71
|
? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', 'data-action': 'accept', title: `This invoice takes the Now in ${system} column.`, onclick: onAccept }, 'Accept change')
|
|
60
72
|
: null;
|
|
61
|
-
const recheck = onRecheck
|
|
73
|
+
const recheck = onRecheck && change.canRecheck !== false
|
|
62
74
|
? h('button', {
|
|
63
|
-
class:
|
|
75
|
+
class: `fx-btn fx-btn--sm${source ? '' : ' fx-btn--icon fx-change__recheck'}`, type: 'button', title: `Read this job from ${system} again. Nothing is sent.`, 'aria-label': source ? 'Recheck' : `Check ${system} again`,
|
|
64
76
|
onclick: async (event) => {
|
|
65
77
|
const button = event.currentTarget;
|
|
66
78
|
button.disabled = true;
|
|
67
79
|
button.classList.add('is-busy');
|
|
68
|
-
|
|
80
|
+
button.setAttribute('aria-busy', 'true');
|
|
81
|
+
try { await onRecheck(); } finally { button.disabled = false; button.classList.remove('is-busy'); button.removeAttribute('aria-busy'); }
|
|
69
82
|
},
|
|
70
|
-
}, h('span', { class: 'fx-lucide', style: '--icon: url(/assets/icons/refresh-cw.svg)', 'aria-hidden': 'true' }))
|
|
83
|
+
}, source ? 'Recheck' : h('span', { class: 'fx-lucide', style: '--icon: url(/assets/icons/refresh-cw.svg)', 'aria-hidden': 'true' }))
|
|
71
84
|
: null;
|
|
72
85
|
const body = h('tbody', {});
|
|
86
|
+
const cell = (area, label, value, num) => h('td', {
|
|
87
|
+
class: num ? 'fx-num' : '', ...(source ? { 'data-area': area, 'data-label': label } : {}),
|
|
88
|
+
}, value);
|
|
73
89
|
const draw = (all) => body.replaceChildren(...(all ? rows : rows.slice(0, FIRST_ROWS)).map((row) => h('tr', {},
|
|
74
|
-
h('th', { scope: 'row' }, row.field),
|
|
75
|
-
h('td', { class: `fx-change__was${row.num ? ' fx-num' : ''}` }, row.was),
|
|
76
|
-
h('td', { class: `fx-change__now${row.num ? ' fx-num' : ''}` }, row.now)
|
|
90
|
+
source ? cell('field', 'Amount', row.field) : h('th', { scope: 'row' }, row.field),
|
|
91
|
+
source ? cell('source', 'Source invoice', row.was, true) : h('td', { class: `fx-change__was${row.num ? ' fx-num' : ''}` }, row.was),
|
|
92
|
+
source ? cell('flow', 'Flow job lines', row.now, true) : h('td', { class: `fx-change__now${row.num ? ' fx-num' : ''}` }, row.now),
|
|
93
|
+
source ? cell('difference', 'Difference', row.difference, true) : null)));
|
|
77
94
|
draw(false);
|
|
95
|
+
const table = h('table', { class: 'fx-change__table' },
|
|
96
|
+
h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, source ? 'Amount' : 'Field'),
|
|
97
|
+
h('th', { scope: 'col' }, source ? 'Source invoice' : 'Was'),
|
|
98
|
+
h('th', { scope: 'col' }, source ? 'Flow job lines' : `Now in ${system}`),
|
|
99
|
+
source ? h('th', { scope: 'col' }, 'Difference') : null)), body);
|
|
100
|
+
if (source) ui.cardRows(table, { areas: ['field field', 'source flow', 'difference difference'], columns: 'repeat(2, minmax(0, 1fr))' });
|
|
101
|
+
const inFlow = (href, label) => h('a', { class: 'fx-btn fx-btn--sm', href, onclick: (event) => ui.go(event) }, label);
|
|
102
|
+
const sourceSteps = source ? h('div', { class: 'fx-change__steps' },
|
|
103
|
+
h('p', { class: 'fx-change__note' }, `Source invoice: ${change.sourceInvoiceRef ?? 'Not supplied'}.${change.caseRef ? ` Case: ${change.caseRef}.` : ''}`),
|
|
104
|
+
h('p', {}, 'Flow cannot tell which amount is wrong.'),
|
|
105
|
+
h('p', { class: 'fx-change__note' }, 'Difference = Flow job lines minus source invoice. Do not divide a source invoice total between jobs.'),
|
|
106
|
+
source.unknownTaxLines.length ? h('p', {}, `Tax is unknown on ${source.unknownTaxLines.map((line) => `line ${line.position}${line.description ? ` (${line.description})` : ''}`).join(', ')}. Flow cannot calculate GST or the total.`) : null,
|
|
107
|
+
h('ol', {},
|
|
108
|
+
h('li', {}, change.link?.exact ? `Open the job in ${system}, then check its linked invoice.` : `Open ${system}${change.link ? ' with its link' : ' separately'}. Find ${change.caseRef ? `case ${change.caseRef}` : order ?? 'the linked job'}, then check the source invoice.`),
|
|
109
|
+
h('li', {}, 'Check each price, quantity, discount and tax value against the source facts.'),
|
|
110
|
+
h('li', {}, 'Correct only facts you can verify. Do not choose tax only to remove this difference.'),
|
|
111
|
+
h('li', {}, 'If a Flow mapping is wrong, correct it in Price codes or Price list.'),
|
|
112
|
+
h('li', {}, change.canRecheck === false ? 'Ask an administrator to restore the source connection, then return here.' : 'Return here and select Recheck. Flow reads the source again. It does not send this invoice.')),
|
|
113
|
+
h('div', { class: 'fx-change__acts' }, inFlow('/invoicing-settings/price-codes', 'Price codes'), inFlow('/price-list', 'Price list')),
|
|
114
|
+
change.canRecheck === false ? h('p', {}, `Flow cannot read this job from ${system}.`) : null,
|
|
115
|
+
h('details', {}, h('summary', {}, 'Saved invoice'),
|
|
116
|
+
h('p', {}, 'These saved Flow amounts do not change when you recheck.'),
|
|
117
|
+
h('dl', { class: 'fx-facts' }, [['Subtotal', 'subtotalCents'], ['GST', 'taxCents'], ['Total', 'totalCents']].flatMap(([label, key]) => [h('dt', {}, label), h('dd', {}, money(source.savedInvoice[key]))])),
|
|
118
|
+
change.seenAt ? h('p', { class: 'fx-change__note' }, `Last source report: ${new Date(change.seenAt).toLocaleString('en-AU')}.`) : null)) : null;
|
|
78
119
|
const more = rows.length > FIRST_ROWS
|
|
79
120
|
? h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: (event) => { draw(true); event.currentTarget.remove(); } }, `Show all ${rows.length}`)
|
|
80
121
|
: null;
|
|
81
|
-
return applyPhoneRule(h('section', { class: 'fx-change', role: 'alert' },
|
|
122
|
+
return applyPhoneRule(h('section', { class: 'fx-change', role: 'alert', 'aria-label': source ? 'Source invoice comparison' : 'Job comparison' },
|
|
82
123
|
h('div', { class: 'fx-change__head' },
|
|
83
124
|
h('p', {}, h('strong', {}, 'Problem.'), ' ', changeSummary(change, rows.filter((row) => row.field !== 'Total').length)),
|
|
84
125
|
h('div', { class: 'fx-change__acts' }, link, accept, recheck)),
|
|
85
|
-
!change.link && order ? h('p', { class: 'fx-change__note' }, `Find ${order} in ${system} to fix it there.`) : null,
|
|
86
|
-
rows.length ? h('div', { class: 'fx-change__scroll' },
|
|
87
|
-
|
|
88
|
-
body)) : null,
|
|
126
|
+
!source && !change.link && order ? h('p', { class: 'fx-change__note' }, `Find ${order} in ${system} to fix it there.`) : null,
|
|
127
|
+
rows.length ? h('div', { class: 'fx-change__scroll' }, table) : null,
|
|
128
|
+
sourceSteps,
|
|
89
129
|
more), 'jobChangeBanner');
|
|
90
130
|
}
|
|
91
131
|
|
|
@@ -114,9 +154,17 @@ export function jobChangeReview(ui, change, { money, canAccept = false, unsaved
|
|
|
114
154
|
},
|
|
115
155
|
// Reads this one job from the work system again, then every check runs.
|
|
116
156
|
onRecheck: async () => {
|
|
157
|
+
if (unsaved()) {
|
|
158
|
+
toast('Save or discard your changes before Recheck.', 'danger');
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
117
161
|
try {
|
|
118
162
|
const res = await flow(`/api/invoicing/jobs/${change.jobId}/recheck`, { method: 'POST', body: '{}' });
|
|
119
|
-
|
|
163
|
+
const pending = Boolean(res.invoice?.jobChange || res.invoice?.problems?.length);
|
|
164
|
+
const cached = res.outcome === 'fresh';
|
|
165
|
+
toast(cached ? `No new source read. Try again in ${res.retryAfterSeconds ?? 10} seconds.`
|
|
166
|
+
: pending ? 'Still pending. Check the comparison and the remaining problems.'
|
|
167
|
+
: 'Source check complete. No invoice problem remains.', cached ? 'info' : pending ? 'danger' : 'success');
|
|
120
168
|
if (res.invoice) await done(res.invoice);
|
|
121
169
|
} catch (error) {
|
|
122
170
|
toast(error.message, 'danger');
|
package/server/ui/jobs.js
CHANGED
|
@@ -1136,7 +1136,7 @@ export async function renderJobs(main, ui) {
|
|
|
1136
1136
|
? link({ href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } },
|
|
1137
1137
|
`${o.invoiceStatus === 'draft' ? 'Draft' : 'Invoice'} ${o.invoiceNumber ?? ''}`.trim() + (stage ? ` · ${INVOICE_STAGES[stage]}` : ''))
|
|
1138
1138
|
: choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
|
|
1139
|
-
: stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
|
|
1139
|
+
: stage === 'drafted' ? 'Not drafted' : stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
|
|
1140
1140
|
const rest = found.filter((f) => f.go !== 'choose');
|
|
1141
1141
|
// What to fix: one chip a problem, beside the stage, so the panel keeps
|
|
1142
1142
|
// one layout with or without a fix. A chip is a toggle: a click opens
|
|
@@ -1181,7 +1181,7 @@ export async function renderJobs(main, ui) {
|
|
|
1181
1181
|
}
|
|
1182
1182
|
// Each fix's link: Flow's own invoice, the job in the work system, or
|
|
1183
1183
|
// the Flow page where the fix is made (job-board.js GO).
|
|
1184
|
-
function fixLink(go, tip = null) {
|
|
1184
|
+
function fixLink(go, tip = null, rule = null) {
|
|
1185
1185
|
const button = (icon, text, attrs) => h(attrs.href ? 'a' : 'button', {
|
|
1186
1186
|
class: 'fx-btn fx-btn--sm fx-btn--primary fx-task__fixgo', title: tip, ...(attrs.href ? {} : { type: 'button' }), ...attrs,
|
|
1187
1187
|
}, lucide(h, icon), text);
|
|
@@ -1189,7 +1189,7 @@ export async function renderJobs(main, ui) {
|
|
|
1189
1189
|
if ((go === 'review' || go === 'invoice') && o?.invoiceId) return inFlow('file-text', `/invoices/${o.invoiceId}`, 'Invoice');
|
|
1190
1190
|
if (go === 'work' && job.link?.url) return button('external-link', system, { href: job.link.url, target: '_blank', rel: 'noopener' });
|
|
1191
1191
|
if (go === 'price-list') return inFlow('tag', '/price-list', 'Price list');
|
|
1192
|
-
if (go === '
|
|
1192
|
+
if (go === 'rule' && rule) return inFlow('list-checks', `/invoicing-settings/invoices?rule=${encodeURIComponent(rule)}`, 'Review rule');
|
|
1193
1193
|
if (go === 'integrations' && ui.me?.person?.role === 'owner') return inFlow('plug', '/integrations', 'Integrations');
|
|
1194
1194
|
return null;
|
|
1195
1195
|
}
|
|
@@ -1230,7 +1230,7 @@ export async function renderJobs(main, ui) {
|
|
|
1230
1230
|
return h('section', { class: 'fx-task__fixitem', 'aria-label': f.label }, reviewBody);
|
|
1231
1231
|
}
|
|
1232
1232
|
return h('section', { class: 'fx-task__fixitem', 'aria-label': f.label },
|
|
1233
|
-
h('strong', {}, f.label), h('p', {}, f.text), f.todo ? h('p', { class: 'fx-dim' }, f.todo) : null, fixLink(f.go));
|
|
1233
|
+
h('strong', {}, f.label), h('p', {}, f.text), f.todo ? h('p', { class: 'fx-dim' }, f.todo) : null, fixLink(f.go, null, f.rule));
|
|
1234
1234
|
}
|
|
1235
1235
|
const sideButton = h('button', {
|
|
1236
1236
|
class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Side panel', title: 'Side panel', 'aria-pressed': 'false',
|
package/server/ui/nav-pins.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Which sidebar pages a person has pinned. Stored with the account in
|
|
2
2
|
// people.views.nav (PUT /api/me/views/nav). No table of its own.
|
|
3
|
-
// Jobs and Invoices stay pinned: a request that leaves them out is saved
|
|
3
|
+
// Home, Jobs and Invoices stay pinned: a request that leaves them out is saved
|
|
4
4
|
// with them put back. A page Flow does not know is dropped.
|
|
5
5
|
|
|
6
6
|
export const NAV_PAGES = Object.freeze([
|
|
7
|
+
'home',
|
|
7
8
|
'jobs',
|
|
8
9
|
'captures',
|
|
9
10
|
'invoices',
|
|
@@ -16,12 +17,13 @@ export const NAV_PAGES = Object.freeze([
|
|
|
16
17
|
'integrations',
|
|
17
18
|
]);
|
|
18
19
|
|
|
19
|
-
export const NAV_LOCKED = Object.freeze(['jobs', 'invoices']);
|
|
20
|
+
export const NAV_LOCKED = Object.freeze(['home', 'jobs', 'invoices']);
|
|
20
21
|
|
|
21
|
-
// Today's sidebar, before a person has chosen: daily work (Jobs, Capture,
|
|
22
|
+
// Today's sidebar, before a person has chosen: Home, daily work (Jobs, Capture,
|
|
22
23
|
// Invoices, Contacts, Reconcile), Price list and Rules. Automations, Custom fields
|
|
23
24
|
// and Integrations start in More.
|
|
24
25
|
export const NAV_DEFAULT = Object.freeze([
|
|
26
|
+
'home',
|
|
25
27
|
'jobs',
|
|
26
28
|
'captures',
|
|
27
29
|
'invoices',
|
package/server/ui/reconcile.js
CHANGED
|
@@ -470,13 +470,15 @@ async function renderGap(main, ui, id) {
|
|
|
470
470
|
};
|
|
471
471
|
});
|
|
472
472
|
results.replaceChildren(...[
|
|
473
|
-
job.open ? null : h('p', { class: 'fx-notice' }, 'This job has an invoice already.'),
|
|
473
|
+
job.open || job.unclear ? null : h('p', { class: 'fx-notice' }, 'This job has an invoice already.'),
|
|
474
474
|
candidates.length ? compareView({ ours: jobFacts(job), oursTitle: 'This job', oursSub: system, candidates })
|
|
475
475
|
: h('p', { class: 'fx-empty' }, q.trim() ? 'No Flow invoice matches the search.' : 'Flow found no invoice that may be for this job. Find one, or make it.'),
|
|
476
476
|
].flat().filter(Boolean));
|
|
477
477
|
actions.replaceChildren(...[
|
|
478
478
|
skip(),
|
|
479
|
-
|
|
479
|
+
// An unclear job (a voided or deleted Xero link) is not open to a link,
|
|
480
|
+
// but Make invoice settles it.
|
|
481
|
+
canAct && (job.open || job.unclear) ? h('button', {
|
|
480
482
|
class: `fx-btn${candidates.some((c) => c.best) ? '' : ' fx-btn--primary'}`, type: 'button',
|
|
481
483
|
onclick: () => act(async () => {
|
|
482
484
|
if (job.unclear) await flow(`/api/invoicing/jobs/${job.id}/no-invoice`, { method: 'POST', body: '{}' });
|
package/server/ui/routes.js
CHANGED
|
@@ -22,7 +22,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
22
22
|
const logoDir = join(here, 'logos');
|
|
23
23
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
24
24
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
25
|
-
export const UI_FILES = new Set(['sync-status.js', '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', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
25
|
+
export const UI_FILES = new Set(['sync-status.js', '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', 'contacts.js', 'xero-contact.js', 'jobs.js', 'home.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
26
26
|
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
27
27
|
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
28
28
|
const MAX_AGE = '1h';
|