@arcforgelabs/flow 2026.11.1 → 2026.11.2
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 +27 -10
- package/server/core/invoice-rules.js +25 -10
- package/server/core/job-board.js +12 -4
- package/server/core/ledger.js +20 -6
- package/server/core/line-mapping.js +7 -3
- package/server/core/sending.js +14 -8
- package/server/ui/app.js +2 -2
- package/server/ui/flow.css +7 -3
- package/server/ui/invoice-rules.js +24 -3
- package/server/ui/invoicing.js +42 -6
- package/server/ui/job-board.js +12 -7
- package/server/ui/job-change.js +63 -15
- package/server/ui/jobs.js +4 -4
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.11.
|
|
3
|
+
"version": "2026.11.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.11.
|
|
9
|
+
"version": "2026.11.2",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/api/invoicing.js
CHANGED
|
@@ -313,7 +313,7 @@ async function jobChangeDetail(db, organisationId, invoiceId, work) {
|
|
|
313
313
|
const link = typeof source?.capabilities.jobLink === 'function'
|
|
314
314
|
? source.capabilities.jobLink({ sourceJobRef: change.sourceJobRef, sourceInvoiceRef: change.sourceInvoiceRef })
|
|
315
315
|
: null;
|
|
316
|
-
return { ...change, contact, systemName: source?.name ?? change.sourceSystem, link: link ?? null };
|
|
316
|
+
return { ...change, contact, systemName: source?.name ?? change.sourceSystem, link: link ?? null, canRecheck: typeof source?.capabilities?.readWorkItem === 'function' };
|
|
317
317
|
}
|
|
318
318
|
|
|
319
319
|
// Each invoice's tab (TABS): Map(id -> tab), the ids on each tab, the
|
|
@@ -783,18 +783,35 @@ export function createInvoicingRouter({
|
|
|
783
783
|
const source = work().find((a) => a.id === obligation.sourceSystem) ?? null;
|
|
784
784
|
if (typeof source?.capabilities?.readWorkItem !== 'function') throw new FlowError('not_ready', 'the work system cannot read one job');
|
|
785
785
|
// One read for each job in RECHECK_GAP_MS, whatever the clicks.
|
|
786
|
-
const last = rechecked.get(obligationId)
|
|
786
|
+
const last = rechecked.get(obligationId);
|
|
787
|
+
const retryAfter = (entry) => Math.max(1, Math.ceil((RECHECK_GAP_MS - (Date.now() - entry.at)) / 1000));
|
|
787
788
|
let outcome = 'fresh';
|
|
788
|
-
if (Date.now() - last
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
789
|
+
if (last && (last.state === 'reading' || Date.now() - last.at < RECHECK_GAP_MS)) {
|
|
790
|
+
if (last.state !== 'read') {
|
|
791
|
+
const retryAfterSeconds = retryAfter(last);
|
|
792
|
+
throw new FlowError('not_ready', last.state === 'reading'
|
|
793
|
+
? 'No new source read. The previous recheck is still running. Wait, then recheck again.'
|
|
794
|
+
: 'No new source read. Wait ' + retryAfterSeconds + ' seconds, then recheck again. Previous error: ' + last.error,
|
|
795
|
+
{ outcome: last.state === 'reading' ? 'read_in_progress' : 'cached_failed', retryAfterSeconds });
|
|
796
|
+
}
|
|
797
|
+
} else {
|
|
798
|
+
const entry = { at: Date.now(), state: 'reading', error: null };
|
|
799
|
+
rechecked.set(obligationId, entry);
|
|
800
|
+
const actor = 'person:' + req.person.id;
|
|
801
|
+
try {
|
|
802
|
+
const read = (await readBeforeAct(db, organisationId, [obligation], { work: source, actor, force: true })).get(obligationId);
|
|
803
|
+
if (read.outcome === 'waiting') throw new FlowError('not_ready', source.name + ' could not be read: ' + (read.reason ?? 'the source is unavailable') + '. Wait, then recheck again.');
|
|
804
|
+
await settleJobChange(db, organisationId, obligationId, { actor });
|
|
805
|
+
entry.state = 'read';
|
|
806
|
+
outcome = 'read';
|
|
807
|
+
} catch (error) {
|
|
808
|
+
entry.state = 'failed';
|
|
809
|
+
entry.error = error instanceof FlowError ? error.message : 'The source recheck failed. Wait, then recheck again.';
|
|
810
|
+
throw new FlowError('not_ready', entry.error, { retryAfterSeconds: retryAfter(entry) });
|
|
811
|
+
}
|
|
795
812
|
}
|
|
796
813
|
const invoice = obligation.invoiceId ? await invoiceDetail(db, organisationId, obligation.invoiceId, accounting(), work()) : null;
|
|
797
|
-
res.json({ ok: true, outcome, invoice });
|
|
814
|
+
res.json({ ok: true, outcome, retryAfterSeconds: retryAfter(rechecked.get(obligationId)), invoice });
|
|
798
815
|
}));
|
|
799
816
|
|
|
800
817
|
// A job with no invoice whose match is unclear (ledger.js). The linking
|
|
@@ -197,15 +197,24 @@ function matches(condition, job, context) {
|
|
|
197
197
|
// The reasons the enabled rules give for one job, in rule order. An empty list
|
|
198
198
|
// means no rule stops its draft. `customer` is the customer's own billing
|
|
199
199
|
// settings (invoicing-rules.js).
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
200
|
+
const waitingRules = (rules, job, { customer = {}, now = new Date() } = {}) => rules
|
|
201
|
+
.filter((rule) => rule.enabled && rule.trigger === 'job_ready' && !['pending', 'non_chargeable'].includes(rule.action.kind))
|
|
202
|
+
.filter((rule) => rule.conditions.every((c) => matches(c, job, { customer, now })));
|
|
203
|
+
const waitMessage = (rule) => rule.action.kind === 'skip'
|
|
204
|
+
? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`;
|
|
205
|
+
|
|
206
|
+
// Keep the text-only reason API for draft checks, including evaluation of
|
|
207
|
+
// starter rules before they are saved. Selection/text have one owner here.
|
|
208
|
+
export function ruleReasons(rules, job, options) {
|
|
209
|
+
return waitingRules(rules, job, options).map(waitMessage);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Pending work needs the saved rule identity, just as a Pending invoice does.
|
|
213
|
+
export function ruleWait(rules, job, options) {
|
|
214
|
+
return waitingRules(rules, job, options).map((rule) => ({ code: 'rule',
|
|
215
|
+
message: waitMessage(rule), short: rule.name.slice(0, 40), rule: rule.id,
|
|
216
|
+
resolution: ruleResolution(rule),
|
|
217
|
+
}));
|
|
209
218
|
}
|
|
210
219
|
|
|
211
220
|
// The notes of the enabled pending rules that match one draft, as problems:
|
|
@@ -213,12 +222,17 @@ export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}
|
|
|
213
222
|
// `reference`, the draft's Reference. A pending rule never stops a draft.
|
|
214
223
|
// `fields` names the fields the rule reads, so the invoice page marks the
|
|
215
224
|
// one to fix (the Reference box goes red).
|
|
225
|
+
const ruleResolution = (rule) => {
|
|
226
|
+
if (!rule.id) throw new FlowError('warning_resolution_required', 'A Pending rule needs its saved rule to resolve it.');
|
|
227
|
+
return { kind: 'rule_review' };
|
|
228
|
+
};
|
|
229
|
+
|
|
216
230
|
export function rulePending(rules, job, { customer = {}, now = new Date() } = {}) {
|
|
217
231
|
const context = { customer, now };
|
|
218
232
|
return rules
|
|
219
233
|
.filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'pending')
|
|
220
234
|
.filter((r) => r.conditions.every((c) => matches(c, job, context)))
|
|
221
|
-
.map((r) => ({ code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null, fields: [...new Set(r.conditions.map((c) => c.field))] }));
|
|
235
|
+
.map((r) => ({ code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null, fields: [...new Set(r.conditions.map((c) => c.field))], resolution: ruleResolution(r) }));
|
|
222
236
|
}
|
|
223
237
|
|
|
224
238
|
// Drafts an enabled skip rule matches stay on Pending at Send (sending.js).
|
|
@@ -236,6 +250,7 @@ export function ruleSkip(rules, job, { customer = {}, now = new Date() } = {}) {
|
|
|
236
250
|
short: r.name.slice(0, 40),
|
|
237
251
|
rule: r.id ?? null,
|
|
238
252
|
fields: [...new Set(r.conditions.map((c) => c.field))],
|
|
253
|
+
resolution: ruleResolution(r),
|
|
239
254
|
}));
|
|
240
255
|
}
|
|
241
256
|
|
package/server/core/job-board.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { and, eq, inArray, isNull } from 'drizzle-orm';
|
|
2
2
|
import { contacts, invoices, priceItems, reviewItems } from '../db/schema.js';
|
|
3
|
-
import { listRules } from './invoice-rules.js';
|
|
3
|
+
import { listRules, ruleWait } from './invoice-rules.js';
|
|
4
4
|
import { customerBillingByContact, effectiveSettings, readInvoicingSettings, ruleBlockers } from './invoicing-rules.js';
|
|
5
5
|
import { REVIEW_CHANGED, listObligations } from './ledger.js';
|
|
6
6
|
import { isHistoryHeld } from './history.js';
|
|
@@ -54,8 +54,16 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
54
54
|
: views.sending.has(id) ? 'sending' : null);
|
|
55
55
|
// The invoicing rules a job with no invoice waits on, for every view a
|
|
56
56
|
// draft can still come from.
|
|
57
|
-
const
|
|
58
|
-
|
|
57
|
+
const waiting = (o, v) => {
|
|
58
|
+
if (o.invoiceId || !['in_work', 'ready', 'pending', 'held'].includes(v)) return { waitingFor: [], waitingRules: [] };
|
|
59
|
+
const customer = { ...effectiveSettings(settings, customers.get(o.contactId)), name: nameOf.get(o.contactId) ?? null };
|
|
60
|
+
const waitingRules = ruleWait(rules, o, { customer });
|
|
61
|
+
// Keep the existing text projection for API consumers; links use identity.
|
|
62
|
+
return { waitingRules, waitingFor: [
|
|
63
|
+
...ruleBlockers(o, customer),
|
|
64
|
+
...waitingRules.map((problem) => problem.message),
|
|
65
|
+
] };
|
|
66
|
+
};
|
|
59
67
|
return listed.map(({ obligation: { lineFingerprint: _fingerprint, organisationId: _org, draftBasis: _basis, ...o }, view, reasons }) => ({
|
|
60
68
|
...o,
|
|
61
69
|
canUseNewLines: offers.has(o.id),
|
|
@@ -66,7 +74,7 @@ export async function describeObligations(db, organisationId, listed, { accounti
|
|
|
66
74
|
invoiceProblems: (o.invoiceId && views?.problems.get(o.invoiceId)) || [],
|
|
67
75
|
view,
|
|
68
76
|
reasons,
|
|
69
|
-
|
|
77
|
+
...waiting(o, view),
|
|
70
78
|
}));
|
|
71
79
|
}
|
|
72
80
|
|
package/server/core/ledger.js
CHANGED
|
@@ -349,7 +349,7 @@ async function compareWithDraft(tx, organisationId, obligation, facts) {
|
|
|
349
349
|
}
|
|
350
350
|
const flow = { lines: invoice.lines, contactId: invoice.contactId };
|
|
351
351
|
const compare = compareThreeWays(base, facts, flow, { sent: invoiceSent(invoice, obligation) });
|
|
352
|
-
return { ...compare, base, flow, invoiceStatus: invoice.status };
|
|
352
|
+
return { ...compare, base, flow, invoiceStatus: invoice.status, invoiceTotals: { subtotalCents: invoice.subtotalCents, taxCents: invoice.taxCents, totalCents: invoice.totalCents } };
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
function changedReview(obligation, compare) {
|
|
@@ -479,17 +479,29 @@ export function diffJobLines(wasLines = [], nowLines = []) {
|
|
|
479
479
|
return changes.sort((a, b) => a.position - b.position || (a.change === 'removed' ? -1 : 1));
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
-
// The open
|
|
482
|
+
// The open job-change or source-billing problem, for the invoice page, or
|
|
483
483
|
// null: the job as it was when the invoice was made, as the work system has
|
|
484
484
|
// it now, and whether a person can adopt the change into Flow's own draft
|
|
485
485
|
// ("Adopt changes", useSourceLines). An invoice drafted before the basis was
|
|
486
486
|
// kept compares with the invoice's own lines.
|
|
487
487
|
export async function jobChangeFor(db, organisationId, invoiceId) {
|
|
488
488
|
const [obligation] = await db.select().from(billingObligations)
|
|
489
|
-
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId)
|
|
490
|
-
eq(billingObligations.holdState, 'review'), eq(billingObligations.holdReason, CHANGED_REASON)));
|
|
489
|
+
.where(and(eq(billingObligations.organisationId, organisationId), eq(billingObligations.invoiceId, invoiceId)));
|
|
491
490
|
if (!obligation) return null;
|
|
491
|
+
const changed = obligation.holdState === 'review' && obligation.holdReason === CHANGED_REASON;
|
|
492
|
+
const sourceProblems = sourceBillingProblems(obligation);
|
|
493
|
+
if (!changed && !sourceProblems.length) return null;
|
|
492
494
|
const compare = await compareWithDraft(db, organisationId, obligation, obligation);
|
|
495
|
+
// Source review is only for Flow's draft. Issued documents keep the existing
|
|
496
|
+
// job-change/accounting comparison, never a new source-resolution action.
|
|
497
|
+
const sourceReview = sourceProblems.length && compare.invoiceStatus === 'draft' ? {
|
|
498
|
+
sourceTotals: obligation.sourceTotals ?? null,
|
|
499
|
+
reconstructed: { ...invoiceTotals(obligation.lines), ...(obligation.lines.some((line) => line.taxCode === null) ? { taxCents: null, totalCents: null } : {}) },
|
|
500
|
+
unknownTaxLines: obligation.lines.flatMap((line, index) => line.taxCode === null
|
|
501
|
+
? [{ position: index + 1, description: line.description, itemCode: line.itemCode ?? null }] : []),
|
|
502
|
+
savedInvoice: compare.invoiceTotals,
|
|
503
|
+
} : null;
|
|
504
|
+
if (!changed && !sourceReview) return null;
|
|
493
505
|
const was = obligation.draftBasis
|
|
494
506
|
?? (compare.flow ? { lines: compare.flow.lines, contactId: compare.flow.contactId, totalCents: null } : basisOf(obligation));
|
|
495
507
|
return {
|
|
@@ -497,13 +509,15 @@ export async function jobChangeFor(db, organisationId, invoiceId) {
|
|
|
497
509
|
sourceSystem: obligation.sourceSystem,
|
|
498
510
|
sourceJobRef: obligation.sourceJobRef,
|
|
499
511
|
sourceInvoiceRef: obligation.sourceInvoiceRef,
|
|
512
|
+
caseRef: obligation.caseRef,
|
|
513
|
+
sourceReview,
|
|
500
514
|
seenAt: obligation.sourceSeenAt,
|
|
501
515
|
outcome: compare.outcome,
|
|
502
516
|
sent: compare.sent,
|
|
503
|
-
canAdopt: compare.outcome === 'offer',
|
|
517
|
+
canAdopt: !sourceReview && compare.outcome === 'offer',
|
|
504
518
|
// "Accept change": the draft takes the work system's values, even when
|
|
505
519
|
// the draft changed too. Not once the invoice has left Flow.
|
|
506
|
-
canAccept: !compare.sent && !compare.missing,
|
|
520
|
+
canAccept: !sourceReview && !compare.sent && !compare.missing,
|
|
507
521
|
lines: diffJobLines(was.lines ?? [], obligation.lines ?? []),
|
|
508
522
|
contact: (was.contactId ?? null) === (obligation.contactId ?? null) ? null : { was: was.contactId ?? null, now: obligation.contactId ?? null },
|
|
509
523
|
total: { was: was.totalCents ?? null, now: obligation.totalCents },
|
|
@@ -24,8 +24,10 @@ import { FlowError, notFound } from './errors.js';
|
|
|
24
24
|
//
|
|
25
25
|
// A mapped line keeps the work system's amount and tax flag; the price item
|
|
26
26
|
// gives its code, its text and, when the work system gave none, its price and
|
|
27
|
-
// tax.
|
|
28
|
-
//
|
|
27
|
+
// tax. A closest-name guess cannot supply missing source tax; it needs an
|
|
28
|
+
// explicit tax flag before mapping. With `linePrice: 'list'` the price item's
|
|
29
|
+
// price wins instead. A line no step answers keeps no code, so the ledger
|
|
30
|
+
// holds the job.
|
|
29
31
|
|
|
30
32
|
export const STEPS = Object.freeze([
|
|
31
33
|
'same_code', 'account', 'saved', 'catalog_name', 'price_alias', 'catalog_name_price', 'catalog_score', 'alias',
|
|
@@ -207,7 +209,9 @@ export function mapLine(line, context) {
|
|
|
207
209
|
break;
|
|
208
210
|
}
|
|
209
211
|
case 'catalog_score': {
|
|
210
|
-
|
|
212
|
+
// A guessed item would also supply its tax here (or in the ledger).
|
|
213
|
+
// Leave unknown tax for an exact match or a person's saved choice.
|
|
214
|
+
if (price === null || line.taxCode == null) break;
|
|
211
215
|
const scored = (context.byPrice.get(price) ?? [])
|
|
212
216
|
.map((item) => ({ item, score: nameScore(item, { exact, lookup, original: lineText(original) }) }))
|
|
213
217
|
.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name) || a.item.code.localeCompare(b.item.code));
|
package/server/core/sending.js
CHANGED
|
@@ -50,20 +50,24 @@ const REFUSAL_CODE = /^[a-z0-9_]{1,64}$/i;
|
|
|
50
50
|
const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
|
|
51
51
|
|
|
52
52
|
// `short` is the few words the Invoices list shows; `message` says how to fix
|
|
53
|
-
// it.
|
|
54
|
-
|
|
53
|
+
// it. Draft warnings must name their resolution UI at the owner, not in a
|
|
54
|
+
// separate warning-code inventory. The invoice page dispatches this intent.
|
|
55
|
+
const problem = (code, message, short, resolution, extra = {}) => {
|
|
56
|
+
if (typeof resolution?.kind !== 'string' || !resolution.kind) throw new FlowError('warning_resolution_required', `Draft warning ${code} needs a resolution UI.`);
|
|
57
|
+
return { code, message, ...(short ? { short } : {}), ...extra, resolution };
|
|
58
|
+
};
|
|
55
59
|
|
|
56
60
|
// An issued invoice the accounting system refused. A refusal stored without
|
|
57
61
|
// its few words (before 2026-10-28) shows "Not sent"; its message stays.
|
|
58
|
-
const refusal = (sendError) => (sendError ? { short: 'Not sent', ...sendError } :
|
|
62
|
+
const refusal = (sendError) => (sendError ? { short: 'Not sent', ...sendError } : { code: 'send_failed', message: 'Sending failed.', short: 'Not sent' });
|
|
59
63
|
|
|
60
64
|
// Flow's own checks of one draft. `lines` are its invoice lines.
|
|
61
65
|
export function draftProblems(invoice, lines) {
|
|
62
66
|
const problems = [];
|
|
63
|
-
if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines'));
|
|
64
|
-
else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0'));
|
|
67
|
+
if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines', { kind: 'invoice_edit' }));
|
|
68
|
+
else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0', { kind: 'invoice_edit' }));
|
|
65
69
|
for (const line of lines) {
|
|
66
|
-
if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`, { fields: ['line_item'], line: line.position }));
|
|
70
|
+
if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`, { kind: 'invoice_edit' }, { fields: ['line_item'], line: line.position }));
|
|
67
71
|
}
|
|
68
72
|
return problems;
|
|
69
73
|
}
|
|
@@ -118,14 +122,16 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
118
122
|
// draft was made, or the work system deleted it) waits for a person.
|
|
119
123
|
for (const { obligation } of jobs) {
|
|
120
124
|
for (const reason of sourceBillingProblems(obligation)) {
|
|
121
|
-
const found = problem('job_review', 'Check the source invoice: ' + reason + '.', 'Source tax');
|
|
125
|
+
const found = { ...problem('job_review', 'Check the source invoice: ' + reason + '.', 'Source tax', { kind: 'job_review' }), sourceBilling: true };
|
|
122
126
|
problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), found]);
|
|
123
127
|
}
|
|
124
128
|
}
|
|
125
129
|
const reviewed = jobs.map((j) => j.obligation).filter((o) => o.holdState === 'review');
|
|
126
130
|
for (const obligation of reviewed) {
|
|
127
131
|
const { invoiceId, holdReason: reason } = obligation;
|
|
128
|
-
const
|
|
132
|
+
const resolution = reason === CHANGED_REASON ? { kind: 'job_review' }
|
|
133
|
+
: { kind: 'job_hold', reason: reason ?? 'the job is review' };
|
|
134
|
+
const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`, reviewShort(obligation), resolution);
|
|
129
135
|
problems.set(invoiceId, [...(problems.get(invoiceId) ?? []), found]);
|
|
130
136
|
}
|
|
131
137
|
if (accounting) {
|
package/server/ui/app.js
CHANGED
|
@@ -27,7 +27,7 @@ import { isSearchKey, openSearch } from '/assets/ui/search.js';
|
|
|
27
27
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
28
28
|
import { initialsOf, personLabel } from '/assets/ui/person.js';
|
|
29
29
|
import { startTips } from '/assets/ui/tip.js';
|
|
30
|
-
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, iconPicker, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
30
|
+
import { cardRows, crumbs, dialog, edgeFade, fieldError, h, holdButton, iconPicker, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
31
31
|
import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
|
|
32
32
|
|
|
33
33
|
const $ = (id) => document.getElementById(id);
|
|
@@ -336,7 +336,7 @@ function navigate(event) {
|
|
|
336
336
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
337
337
|
// takes a path, or a link's click event.
|
|
338
338
|
const ui = () => ({
|
|
339
|
-
h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
|
|
339
|
+
h, flow, toast, notice, cardRows, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
|
|
340
340
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
341
341
|
});
|
|
342
342
|
|
package/server/ui/flow.css
CHANGED
|
@@ -230,6 +230,9 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
230
230
|
background: var(--t-danger-bg); color: var(--t-text); font-size: 14px;
|
|
231
231
|
}
|
|
232
232
|
.fx-change p { margin: 0; }
|
|
233
|
+
.fx-change__steps { display: grid; gap: 10px; min-width: 0; overflow-wrap: anywhere; }
|
|
234
|
+
.fx-change__steps ol { display: grid; gap: 8px; margin: 0; padding-left: 20px; }
|
|
235
|
+
.fx-change__steps summary { cursor: pointer; }
|
|
233
236
|
.fx-change strong { color: var(--t-danger); }
|
|
234
237
|
.fx-change__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; }
|
|
235
238
|
.fx-change__acts { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
@@ -838,7 +841,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
|
|
|
838
841
|
each lane scrolls inside itself (jobs.js sets fx-main--board while the
|
|
839
842
|
board shows). */
|
|
840
843
|
.fx-board { --fx-lane: min(85vw, 272px); display: grid; grid-auto-flow: column; grid-auto-columns: var(--fx-lane); justify-content: start; gap: 12px; align-items: start; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 4px; }
|
|
841
|
-
.fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background:
|
|
844
|
+
.fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background: transparent; }
|
|
842
845
|
.fx-board__head { display: flex; flex: none; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 4px 2px; }
|
|
843
846
|
.fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
844
847
|
.fx-board__cards { display: grid; gap: 8px; align-content: start; min-height: 0; max-height: 70vh; padding-right: 2px; overflow-y: auto; scrollbar-width: thin; }
|
|
@@ -860,7 +863,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
|
|
|
860
863
|
.fx-fade[data-edge="right"] { inset: 0 0 0 auto; width: 24px; background: linear-gradient(to left, var(--fx-fade), transparent); }
|
|
861
864
|
.fx-fades[data-fade~="top"] > .fx-fade[data-edge="top"], .fx-fades[data-fade~="bottom"] > .fx-fade[data-edge="bottom"],
|
|
862
865
|
.fx-fades[data-fade~="left"] > .fx-fade[data-edge="left"], .fx-fades[data-fade~="right"] > .fx-fade[data-edge="right"] { opacity: 1; }
|
|
863
|
-
.fx-main--board .fx-frame__view { --fx-fade: var(--t-
|
|
866
|
+
.fx-main--board .fx-frame__view { --fx-fade: var(--t-s1); }
|
|
864
867
|
.fx-board__col.fx-fades { --fx-fade: var(--t-s1); }
|
|
865
868
|
.fx-board__col > .fx-fade[data-edge="bottom"] { border-radius: 0 0 var(--t-radius-lg) var(--t-radius-lg); }
|
|
866
869
|
.fx-frame__body .fx-panel { border: 0; border-radius: 0; box-shadow: none; overflow: visible; }
|
|
@@ -869,7 +872,8 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
|
|
|
869
872
|
.fx-frame thead th { position: sticky; top: 0; z-index: 2; }
|
|
870
873
|
.fx-frame__foot { display: flex; flex: none; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; padding: 6px 12px; border-top: 1px solid var(--fx-line); color: var(--t-dim); font-size: 13px; font-variant-numeric: tabular-nums; }
|
|
871
874
|
.fx-frame__tools { display: flex; align-items: center; gap: 8px; }
|
|
872
|
-
|
|
875
|
+
/* One work surface runs through the lanes, gaps and unused board space. */
|
|
876
|
+
.fx-main--board .fx-frame__body { padding: 12px; background: var(--t-s1); }
|
|
873
877
|
@media (min-width: 821px) and (min-height: 560px) {
|
|
874
878
|
.fx-main--frame { height: 100vh; height: 100dvh; padding-bottom: 16px; display: flex; flex-direction: column; }
|
|
875
879
|
.fx-main--frame > * { flex: none; }
|
|
@@ -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';
|
|
@@ -981,6 +981,20 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
981
981
|
// item. A check on something this page cannot change (the job's
|
|
982
982
|
// practitioner) marks nothing; the banner says it.
|
|
983
983
|
const problemList = invoice?.problems ?? [];
|
|
984
|
+
// Dispatch intents to the real editor or shared job comparison, not a
|
|
985
|
+
// parallel warning-code list. Missing data/unknown intents stay blocked.
|
|
986
|
+
// Adapter warnings retain their existing owner-specific actions.
|
|
987
|
+
const hasRuleReview = (p) => p.resolution?.kind === 'rule_review' && Boolean(p.rule);
|
|
988
|
+
const hasJobHold = (p) => p.resolution?.kind === 'job_hold' && Boolean(invoice?.job?.id)
|
|
989
|
+
&& typeof p.resolution.reason === 'string' && Boolean(p.resolution.reason.trim());
|
|
990
|
+
const hasJobReview = (p) => p.resolution?.kind === 'job_review' && invoice?.jobChange
|
|
991
|
+
&& Boolean(p.sourceBilling) === Boolean(invoice.jobChange.sourceReview);
|
|
992
|
+
// The source check runs first. Its Recheck reveals the ordinary job
|
|
993
|
+
// comparison once the source problem clears; that view is not missing.
|
|
994
|
+
const deferredJobReview = (p) => p.resolution?.kind === 'job_review'
|
|
995
|
+
&& !p.sourceBilling && Boolean(invoice?.jobChange?.sourceReview);
|
|
996
|
+
const unavailable = problemList.filter((p) => p.resolution
|
|
997
|
+
&& p.resolution.kind !== 'invoice_edit' && !hasJobReview(p) && !deferredJobReview(p) && !hasRuleReview(p) && !hasJobHold(p));
|
|
984
998
|
const failed = new Set(problemList.flatMap((p) => p.fields ?? []));
|
|
985
999
|
const badLines = new Set(problemList.filter((p) => p.fields?.includes('line_item')).map((p) => p.line));
|
|
986
1000
|
const boxes = { reference };
|
|
@@ -1146,7 +1160,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1146
1160
|
// it and queues it, the same as Send all ready. With none, Flow issues it
|
|
1147
1161
|
// and keeps it. A Pending draft has no send: fix its problem first.
|
|
1148
1162
|
const to = invoice?.sendsTo ?? context.sendsTo ?? null;
|
|
1149
|
-
const ready = invoice?.view === 'ready';
|
|
1163
|
+
const ready = invoice?.view === 'ready' && !problemList.length;
|
|
1150
1164
|
// A lab's rule keeps a non-chargeable draft in Flow: nothing sends or approves it.
|
|
1151
1165
|
const nonChargeable = invoice?.view === 'non_chargeable';
|
|
1152
1166
|
const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
|
|
@@ -1210,8 +1224,8 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1210
1224
|
const syncIssue = () => {
|
|
1211
1225
|
if (!issueButton) return;
|
|
1212
1226
|
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.';
|
|
1227
|
+
issueButton.disabled = missing.length > 0 || Boolean(invoice?.jobChange) || problemList.length > 0;
|
|
1228
|
+
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
1229
|
};
|
|
1216
1230
|
onContact = syncIssue;
|
|
1217
1231
|
clientInput.addEventListener('input', syncIssue);
|
|
@@ -1236,7 +1250,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1236
1250
|
const jobChange = invoice?.jobChange ?? null;
|
|
1237
1251
|
// The job changed after the invoice was made: its own banner shows what
|
|
1238
1252
|
// changed. Other problems stay one line.
|
|
1239
|
-
const otherProblems =
|
|
1253
|
+
const otherProblems = problemList.filter((p) => !hasJobReview(p));
|
|
1240
1254
|
const offer = otherProblems.find((p) => ['xero_contact_missing', 'xero_contact_ambiguous'].includes(p.code) && (p.offerLink || p.offerCreate));
|
|
1241
1255
|
// A phone shows each problem's short name ("Client not in Xero") on one line.
|
|
1242
1256
|
const shorts = otherProblems.every((p) => p.short) ? [...new Set(otherProblems.map((p) => p.short))].join(', ') : null;
|
|
@@ -1251,10 +1265,32 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1251
1265
|
done: async () => redraw((await flow(`/api/invoicing/invoices/${invoice.id}`)).invoice),
|
|
1252
1266
|
})
|
|
1253
1267
|
: [];
|
|
1268
|
+
// Every matching rule links to its own conditions and existing controls,
|
|
1269
|
+
// including custom rules. The target enforces read-only roles itself.
|
|
1270
|
+
const ruleActions = [...new Map(problemList.filter(hasRuleReview).map((p) => [p.rule, p])).values()]
|
|
1271
|
+
.map((p) => h('a', { class: 'fx-btn fx-btn--sm',
|
|
1272
|
+
href: `/invoicing-settings/invoices?rule=${encodeURIComponent(p.rule)}`,
|
|
1273
|
+
onclick: (event) => go(event),
|
|
1274
|
+
}, `Review ${p.short ?? 'rule'}`));
|
|
1275
|
+
// Non-change holds use the Jobs workflow's existing instructions, not a
|
|
1276
|
+
// comparison that does not exist. Reading them never releases the hold.
|
|
1277
|
+
const holdSteps = fixes({ view: 'review_required', holdState: 'review',
|
|
1278
|
+
reasons: [...new Set(problemList.filter(hasJobHold).map((p) => p.resolution.reason))] });
|
|
1279
|
+
const holdReview = holdSteps.length ? h('section', { 'aria-label': 'Job hold review' },
|
|
1280
|
+
...holdSteps.map((step) => h('p', {}, step.todo)),
|
|
1281
|
+
invoice.job.link?.url ? h('a', { class: 'fx-btn fx-btn--sm', href: invoice.job.link.url, target: '_blank', rel: 'noopener' },
|
|
1282
|
+
`Open in ${invoice.job.systemName ?? 'work system'}`)
|
|
1283
|
+
: h('p', {}, `The source link is unavailable. Check job ${invoice.job.sourceJobRef ?? ''} in the work system, or ask an administrator to restore its link.`),
|
|
1284
|
+
h('p', {}, deleteButton
|
|
1285
|
+
? 'If this unissued draft is no longer needed, use Delete. It remains Pending while the job is in review.'
|
|
1286
|
+
: 'Ask an owner or administrator to review this draft. It remains Pending while the job is in review.')) : null;
|
|
1254
1287
|
const problems = [
|
|
1288
|
+
holdReview,
|
|
1255
1289
|
// Accepting changes the draft for the business: owners and admins only.
|
|
1256
1290
|
jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
|
|
1257
|
-
|
|
1291
|
+
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,
|
|
1292
|
+
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,
|
|
1293
|
+
problemNotice ? (clientActions.length || ruleActions.length ? h('div', { class: 'fx-offer' }, problemNotice, ...clientActions, ...ruleActions) : problemNotice) : null,
|
|
1258
1294
|
];
|
|
1259
1295
|
// A red field names the banner as its reason (for a screen reader).
|
|
1260
1296
|
const banner = problemNotice;
|
package/server/ui/job-board.js
CHANGED
|
@@ -126,7 +126,16 @@ function reasonFix(reason) {
|
|
|
126
126
|
// system's) as a job fix. Flow's own invoice problems keep their words. A
|
|
127
127
|
// problem an accounting system found stays on the invoice page; the job only
|
|
128
128
|
// says that its invoice needs a fix.
|
|
129
|
+
function ruleFix(problem) {
|
|
130
|
+
const supported = problem.resolution?.kind === 'rule_review' && Boolean(problem.rule);
|
|
131
|
+
return { label: problem.short ?? 'Stopped by a rule', text: sentence(problem.message),
|
|
132
|
+
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.',
|
|
133
|
+
go: supported ? 'rule' : null, rule: problem.rule ?? null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
129
137
|
function draftFix(problem) {
|
|
138
|
+
if (problem.resolution?.kind === 'rule_review') return ruleFix(problem);
|
|
130
139
|
const code = problem.code ?? '';
|
|
131
140
|
const fix = (label, todo) => ({ label, text: sentence(problem.message ?? label), todo, go: 'invoice' });
|
|
132
141
|
if (code === 'no_lines') return fix('No lines', 'Open the invoice and add the lines.');
|
|
@@ -141,7 +150,7 @@ function draftFix(problem) {
|
|
|
141
150
|
// Where a fix's link goes: Flow's invoice, the job in the work system, a
|
|
142
151
|
// Flow settings page, `review`: the job's Review sidecar, or `choose`: Make
|
|
143
152
|
// invoice and Link invoice buttons (jobs.js draws them).
|
|
144
|
-
export const GO = ['invoice', 'work', 'price-list', '
|
|
153
|
+
export const GO = ['invoice', 'work', 'price-list', 'rule', 'integrations', 'review', 'choose'];
|
|
145
154
|
|
|
146
155
|
// What a person must fix on a job, in order: [{ label, text, todo, go }];
|
|
147
156
|
// `todo` is null when the buttons are the instruction.
|
|
@@ -152,11 +161,7 @@ export function fixes(o) {
|
|
|
152
161
|
if (invoiceStage(o) !== 'problem') return [];
|
|
153
162
|
const found = [];
|
|
154
163
|
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
|
-
}
|
|
164
|
+
found.push(...(o.waitingRules ?? []).map(ruleFix));
|
|
160
165
|
if (o.invoiceStatus === 'void') {
|
|
161
166
|
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
167
|
}
|
|
@@ -164,7 +169,7 @@ export function fixes(o) {
|
|
|
164
169
|
found.push(...(o.invoiceProblems ?? []).filter((p) => !(held && p.code === 'job_review')).map((p) => draftFix(p)));
|
|
165
170
|
const seen = new Set();
|
|
166
171
|
return found.filter((f) => {
|
|
167
|
-
const key = `${f.label}\u0000${f.text}`;
|
|
172
|
+
const key = `${f.rule ?? ''}\u0000${f.label}\u0000${f.text}`;
|
|
168
173
|
if (seen.has(key)) return false;
|
|
169
174
|
seen.add(key);
|
|
170
175
|
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',
|