@arcforgelabs/flow 2026.10.40 → 2026.10.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +15 -5
- package/server/core/invoice-rules.js +49 -8
- package/server/core/job-match.js +2 -0
- package/server/core/sending.js +33 -5
- package/server/ui/flow.css +24 -38
- package/server/ui/invoice-rules.js +38 -13
- package/server/ui/invoicing.js +16 -54
- package/server/ui/jobs.js +15 -172
- package/server/ui/reconcile.js +279 -95
- package/server/ui/routes.js +1 -1
- package/server/ui/view-bar.js +236 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.42",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.42",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/api/invoicing.js
CHANGED
|
@@ -43,7 +43,8 @@ import {
|
|
|
43
43
|
// POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
|
|
44
44
|
// POST /invoices/mark-not-ready { invoiceIds }: back to To check
|
|
45
45
|
// POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
|
|
46
|
-
// accounting system { issueDate, invoiceIds? (only these
|
|
46
|
+
// accounting system { issueDate, invoiceIds? (only these; one that fails
|
|
47
|
+
// a check now stays on Pending and counts as held) }
|
|
47
48
|
// POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
|
|
48
49
|
// GET /invoices/:id one invoice: lines, contact, job, issued versions, and where it sends
|
|
49
50
|
// GET /invoices/:id/pdf?version= the invoice as a PDF, drawn by Flow (server/core/invoice-pdf.js),
|
|
@@ -542,8 +543,10 @@ export function createInvoicingRouter({
|
|
|
542
543
|
// the job at its source or adopt the change (ledger.js), as Move to
|
|
543
544
|
// Ready and Send do.
|
|
544
545
|
const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
|
|
545
|
-
|
|
546
|
-
|
|
546
|
+
// A lab's pending rule (invoice-rules.js) is a gate too: fix its cause
|
|
547
|
+
// or turn the rule off.
|
|
548
|
+
const held = problems.find((p) => p.code === 'job_review' || p.code === 'rule');
|
|
549
|
+
if (held) throw new FlowError('not_ready', `${held.message.replace(/\.?$/, '.')} Nothing was approved.`);
|
|
547
550
|
const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
|
|
548
551
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} issued invoice ${issued.number} (version ${issued.issuedVersion})`);
|
|
549
552
|
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
|
|
@@ -773,7 +776,7 @@ export function createInvoicingRouter({
|
|
|
773
776
|
// with their tests, and the values Flow already holds for each kind of menu.
|
|
774
777
|
router.get('/rule-fields', ...signedIn, handle(log, async (req, res) => {
|
|
775
778
|
const organisationId = await organisationOf(req);
|
|
776
|
-
const [statuses, customers, items] = await Promise.all([
|
|
779
|
+
const [statuses, customers, items, practitioners] = await Promise.all([
|
|
777
780
|
workStatuses(db, organisationId),
|
|
778
781
|
db.select({ value: contacts.id, label: contacts.name }).from(contacts)
|
|
779
782
|
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')))
|
|
@@ -781,13 +784,20 @@ export function createInvoicingRouter({
|
|
|
781
784
|
db.select({ value: priceItems.code, label: priceItems.name }).from(priceItems)
|
|
782
785
|
.where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)))
|
|
783
786
|
.orderBy(asc(priceItems.code)).limit(1000),
|
|
787
|
+
// The practitioners on the jobs, as the work system wrote them, most used first.
|
|
788
|
+
db.select({ value: billingObligations.practitioner, n: sql`count(*)::int` }).from(billingObligations)
|
|
789
|
+
.where(and(eq(billingObligations.organisationId, organisationId), sql`coalesce(trim(${billingObligations.practitioner}), '') <> ''`))
|
|
790
|
+
.groupBy(billingObligations.practitioner).orderBy(sql`count(*) desc`, asc(billingObligations.practitioner)).limit(500),
|
|
784
791
|
]);
|
|
785
792
|
res.json({
|
|
786
793
|
ok: true,
|
|
787
|
-
fields: Object.entries(FIELDS).map(([key, f]) => ({
|
|
794
|
+
fields: Object.entries(FIELDS).map(([key, f]) => ({
|
|
795
|
+
key, label: f.label, kind: f.kind, options: f.options ?? null, tests: TESTS[f.kind], draftOnly: Boolean(f.draftOnly),
|
|
796
|
+
})),
|
|
788
797
|
actions: ACTIONS,
|
|
789
798
|
options: {
|
|
790
799
|
statuses: statuses.map((s) => ({ value: s.value, label: s.label })),
|
|
800
|
+
practitioners: practitioners.map((p) => ({ value: p.value, label: `${p.value} (${p.n})` })),
|
|
791
801
|
customers,
|
|
792
802
|
items: [
|
|
793
803
|
...items.map((i) => ({ value: i.value, label: `${i.value} · ${i.label}` })),
|
|
@@ -9,7 +9,10 @@ import { FlowError, notFound } from './errors.js';
|
|
|
9
9
|
// When job_ready: the job is ready to invoice and has no invoice yet.
|
|
10
10
|
// If every condition matches: a field of the job, its customer or its
|
|
11
11
|
// lines, a test, and a value.
|
|
12
|
-
// Then wait: the job waits for a person; skip: rules never draft it
|
|
12
|
+
// Then wait: the job waits for a person; skip: rules never draft it;
|
|
13
|
+
// pending: Flow drafts it, and the draft is Pending with the rule's
|
|
14
|
+
// note, which says what to fix (for example a Reference with no
|
|
15
|
+
// real practitioner).
|
|
13
16
|
//
|
|
14
17
|
// The rules read only Flow's own records, so they work with or without an
|
|
15
18
|
// accounting integration. The earlier fixed settings (old job limit, required
|
|
@@ -19,6 +22,7 @@ const MAX_RULES = 100;
|
|
|
19
22
|
const MAX_CONDITIONS = 10;
|
|
20
23
|
const invalid = (message) => new FlowError('invalid', message);
|
|
21
24
|
|
|
25
|
+
const MAX_NOTE = 200;
|
|
22
26
|
const day = (date) => new Date(`${String(date).slice(0, 10)}T00:00:00Z`);
|
|
23
27
|
const norm = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
24
28
|
// A line's key: its price item code, else the label line mapping gave it (a
|
|
@@ -40,7 +44,9 @@ export const FIELDS = Object.freeze({
|
|
|
40
44
|
job_age_days: { label: 'Job age (days)', kind: 'number', read: (j, { now }) => (j.jobDate ? Math.floor((now - day(j.jobDate)) / 86_400_000) : null) },
|
|
41
45
|
job_total: { label: 'Job total', kind: 'money', read: (j) => (j.totalCents ?? 0) / 100 },
|
|
42
46
|
job_status: { label: 'Job status', kind: 'choice', options: 'statuses', read: (j) => norm(j.sourceStatus) || null },
|
|
43
|
-
practitioner: { label: 'Practitioner', kind: 'text', read: (j, { customer }) => j.practitioner || customer.defaultPractitioner || null },
|
|
47
|
+
practitioner: { label: 'Practitioner', kind: 'text', options: 'practitioners', read: (j, { customer }) => j.practitioner || customer.defaultPractitioner || null },
|
|
48
|
+
// Only a draft has a Reference, so only a pending rule may read it.
|
|
49
|
+
reference: { label: 'Reference', kind: 'text', draftOnly: true, read: (j) => j.reference || null },
|
|
44
50
|
customer: { label: 'Customer', kind: 'choice', options: 'customers', read: (j) => j.contactId },
|
|
45
51
|
customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
|
|
46
52
|
line_item: { label: 'Line item', kind: 'items', options: 'items', read: (j) => (j.lines ?? []).flatMap((l) => [norm(l.itemCode), norm(l.label)].filter(Boolean)) },
|
|
@@ -51,13 +57,15 @@ export const TESTS = Object.freeze({
|
|
|
51
57
|
number: ['more_than', 'at_most', 'is', 'is_not', 'is_empty'],
|
|
52
58
|
money: ['more_than', 'at_most', 'is', 'is_not'],
|
|
53
59
|
choice: ['is', 'is_not', 'is_one_of', 'is_empty'],
|
|
54
|
-
text: ['is', 'is_not', 'is_empty', 'is_not_empty'],
|
|
60
|
+
text: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'contains', 'does_not_contain', 'starts_with', 'does_not_start_with', 'has_number', 'one_word', 'is_empty', 'is_not_empty'],
|
|
55
61
|
boolean: ['is'],
|
|
56
62
|
items: ['has', 'has_not'],
|
|
57
63
|
share: ['is', 'is_not'],
|
|
58
64
|
});
|
|
59
65
|
|
|
60
|
-
export const ACTIONS = Object.freeze(['wait', 'skip']);
|
|
66
|
+
export const ACTIONS = Object.freeze(['wait', 'skip', 'pending']);
|
|
67
|
+
// Tests that take no value.
|
|
68
|
+
const NO_VALUE = new Set(['is_empty', 'is_not_empty', 'has_number', 'one_word']);
|
|
61
69
|
|
|
62
70
|
function readCondition(raw, index) {
|
|
63
71
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid(`condition ${index + 1} must be an object`);
|
|
@@ -65,13 +73,13 @@ function readCondition(raw, index) {
|
|
|
65
73
|
if (!field) throw invalid(`condition ${index + 1}: unknown field ${raw.field}`);
|
|
66
74
|
if (!TESTS[field.kind].includes(raw.test)) throw invalid(`condition ${index + 1}: ${field.label} takes ${TESTS[field.kind].join(', ')}`);
|
|
67
75
|
const out = { field: raw.field, test: raw.test };
|
|
68
|
-
if (
|
|
76
|
+
if (NO_VALUE.has(raw.test)) return out;
|
|
69
77
|
const { value } = raw;
|
|
70
78
|
if (field.kind === 'boolean') {
|
|
71
79
|
if (typeof value !== 'boolean') throw invalid(`condition ${index + 1}: the value must be true or false`);
|
|
72
80
|
} else if (['number', 'money', 'share'].includes(field.kind)) {
|
|
73
81
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1e9) throw invalid(`condition ${index + 1}: the value must be a number`);
|
|
74
|
-
} else if (raw.test
|
|
82
|
+
} else if (['is_one_of', 'is_not_one_of'].includes(raw.test)) {
|
|
75
83
|
if (!Array.isArray(value) || !value.length || value.length > 50 || value.some((v) => typeof v !== 'string' || !v.trim() || v.length > 200)) {
|
|
76
84
|
throw invalid(`condition ${index + 1}: pick 1 to 50 values`);
|
|
77
85
|
}
|
|
@@ -98,7 +106,17 @@ export function readRule(raw) {
|
|
|
98
106
|
throw invalid(`the rule needs 1 to ${MAX_CONDITIONS} conditions`);
|
|
99
107
|
}
|
|
100
108
|
if (!ACTIONS.includes(raw.action?.kind)) throw invalid(`the action must be one of ${ACTIONS.join(', ')}`);
|
|
101
|
-
|
|
109
|
+
const conditions = raw.conditions.map(readCondition);
|
|
110
|
+
const action = { kind: raw.action.kind };
|
|
111
|
+
if (action.kind === 'pending') {
|
|
112
|
+
const note = typeof raw.action.note === 'string' ? raw.action.note.trim() : '';
|
|
113
|
+
if (!note || note.length > MAX_NOTE) throw invalid(`a pending rule needs a note of ${MAX_NOTE} characters at most: what a person must fix`);
|
|
114
|
+
action.note = note;
|
|
115
|
+
} else {
|
|
116
|
+
const early = conditions.find((c) => FIELDS[c.field].draftOnly);
|
|
117
|
+
if (early) throw invalid(`${FIELDS[early.field].label} is on the draft only: use Then "Pending, with a note"`);
|
|
118
|
+
}
|
|
119
|
+
return { name, enabled, conditions, action };
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
function matches(condition, job, context) {
|
|
@@ -107,6 +125,18 @@ function matches(condition, job, context) {
|
|
|
107
125
|
const { test, value } = condition;
|
|
108
126
|
if (test === 'is_empty') return have === null || have === '' || have === undefined;
|
|
109
127
|
if (test === 'is_not_empty') return !(have === null || have === '' || have === undefined);
|
|
128
|
+
if (field.kind === 'text' && !['is', 'is_not', 'is_one_of'].includes(test)) {
|
|
129
|
+
// An empty text matches no test that reads its words; is_empty finds it.
|
|
130
|
+
const left = norm(have);
|
|
131
|
+
if (!left) return false;
|
|
132
|
+
if (test === 'is_not_one_of') return !value.some((v) => norm(v) === left);
|
|
133
|
+
if (test === 'has_number') return /\d/.test(left);
|
|
134
|
+
if (test === 'one_word') return !left.includes(' ');
|
|
135
|
+
if (test === 'contains') return left.includes(norm(value));
|
|
136
|
+
if (test === 'does_not_contain') return !left.includes(norm(value));
|
|
137
|
+
if (test === 'starts_with') return left.startsWith(norm(value));
|
|
138
|
+
if (test === 'does_not_start_with') return !left.startsWith(norm(value));
|
|
139
|
+
}
|
|
110
140
|
switch (field.kind) {
|
|
111
141
|
case 'items': {
|
|
112
142
|
const found = have.includes(norm(value));
|
|
@@ -142,13 +172,24 @@ export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}
|
|
|
142
172
|
const context = { customer, now };
|
|
143
173
|
const reasons = [];
|
|
144
174
|
for (const rule of rules) {
|
|
145
|
-
if (!rule.enabled || rule.trigger !== 'job_ready') continue;
|
|
175
|
+
if (!rule.enabled || rule.trigger !== 'job_ready' || rule.action.kind === 'pending') continue;
|
|
146
176
|
if (!rule.conditions.every((c) => matches(c, job, context))) continue;
|
|
147
177
|
reasons.push(rule.action.kind === 'skip' ? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`);
|
|
148
178
|
}
|
|
149
179
|
return reasons;
|
|
150
180
|
}
|
|
151
181
|
|
|
182
|
+
// The notes of the enabled pending rules that match one draft, as problems:
|
|
183
|
+
// they put the draft on Pending (sending.js). `job` is the draft's job with
|
|
184
|
+
// `reference`, the draft's Reference. A pending rule never stops a draft.
|
|
185
|
+
export function rulePending(rules, job, { customer = {}, now = new Date() } = {}) {
|
|
186
|
+
const context = { customer, now };
|
|
187
|
+
return rules
|
|
188
|
+
.filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'pending')
|
|
189
|
+
.filter((r) => r.conditions.every((c) => matches(c, job, context)))
|
|
190
|
+
.map((r) => ({ code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null }));
|
|
191
|
+
}
|
|
192
|
+
|
|
152
193
|
// The starter rules made from the earlier fixed settings.
|
|
153
194
|
export function starterRules(settings) {
|
|
154
195
|
const rules = [
|
package/server/core/job-match.js
CHANGED
|
@@ -97,6 +97,8 @@ export async function listReconcile(db, organisationId) {
|
|
|
97
97
|
practitioner: billingObligations.practitioner,
|
|
98
98
|
jobDate: billingObligations.jobDate,
|
|
99
99
|
totalCents: billingObligations.totalCents,
|
|
100
|
+
// An unclear accounting match: "Make invoice" settles it first.
|
|
101
|
+
unclear: sql`${billingObligations.accountingState} = 'ambiguous'`.mapWith(Boolean),
|
|
100
102
|
possible: sql`(select count(*) from invoices i
|
|
101
103
|
where i.organisation_id = ${billingObligations.organisationId} and i.contact_id = ${billingObligations.contactId}
|
|
102
104
|
and i.status <> 'void' and i.total_cents = ${billingObligations.totalCents}
|
package/server/core/sending.js
CHANGED
|
@@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
3
|
import { billingObligations, invoiceLines, invoices } from '../db/schema.js';
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
|
+
import { listRules, rulePending } from './invoice-rules.js';
|
|
6
|
+
import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
|
|
5
7
|
import { issueInvoice } from './invoices.js';
|
|
6
8
|
import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
|
|
7
9
|
import { soleOrganisation } from './pipeline.js';
|
|
@@ -14,7 +16,8 @@ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
|
14
16
|
// a total above zero, and a price item (or, for a line such as a rush
|
|
15
17
|
// charge, an account) on every line. When an accounting
|
|
16
18
|
// system is connected, its own checks are added (for Xero: the customer
|
|
17
|
-
// matches one Xero contact, every item is in Xero)
|
|
19
|
+
// matches one Xero contact, every item is in Xero), and the lab's pending
|
|
20
|
+
// rules (invoice-rules.js) add their notes. A draft with a problem is
|
|
18
21
|
// Pending (the "problem" view). A draft without one is Ready.
|
|
19
22
|
//
|
|
20
23
|
// Send all ready issues every ready draft (each gets its number) and, when an
|
|
@@ -77,10 +80,25 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
77
80
|
const found = draftProblems(draft, linesOf.get(draft.id) ?? []);
|
|
78
81
|
if (found.length) problems.set(draft.id, found);
|
|
79
82
|
}
|
|
83
|
+
const jobs = await db.select({ obligation: billingObligations, reference: invoices.reference }).from(billingObligations)
|
|
84
|
+
.innerJoin(invoices, eq(invoices.id, billingObligations.invoiceId))
|
|
85
|
+
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids)));
|
|
86
|
+
// The lab's pending rules (invoice-rules.js): a draft from a job that
|
|
87
|
+
// matches one waits on Pending with the rule's note, such as a Reference
|
|
88
|
+
// with no real practitioner.
|
|
89
|
+
const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && r.action.kind === 'pending');
|
|
90
|
+
if (rules.length && jobs.length) {
|
|
91
|
+
const settings = await readInvoicingSettings(db, organisationId);
|
|
92
|
+
const customers = await customerBillingByContact(db, organisationId, jobs.map((j) => j.obligation.contactId));
|
|
93
|
+
for (const { obligation, reference } of jobs) {
|
|
94
|
+
const customer = effectiveSettings(settings, customers.get(obligation.contactId));
|
|
95
|
+
const found = rulePending(rules, { ...obligation, reference }, { customer });
|
|
96
|
+
if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
80
99
|
// A draft whose job the ledger sent to review (the job changed after the
|
|
81
100
|
// draft was made, or the work system deleted it) waits for a person.
|
|
82
|
-
const reviewed =
|
|
83
|
-
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids), eq(billingObligations.holdState, 'review')));
|
|
101
|
+
const reviewed = jobs.map((j) => j.obligation).filter((o) => o.holdState === 'review');
|
|
84
102
|
for (const obligation of reviewed) {
|
|
85
103
|
const { invoiceId, holdReason: reason } = obligation;
|
|
86
104
|
const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`, reviewShort(obligation));
|
|
@@ -201,11 +219,21 @@ export async function sendAllReady(db, organisationId, { accounting = null, work
|
|
|
201
219
|
let views = await invoiceViews(db, organisationId, { accounting });
|
|
202
220
|
const accountingRead = views.ready.length ? await readAccountingFirst(db, organisationId, { accounting, now }) : { outcome: 'fresh' };
|
|
203
221
|
if (accountingRead.changed) views = await invoiceViews(db, organisationId, { accounting });
|
|
222
|
+
// Every check runs again at the press, as a gate: a ticked draft that
|
|
223
|
+
// fails one now (someone edited it, its job changed, a rule matches) is not
|
|
224
|
+
// sent and stays on Pending with its problem. The rest go.
|
|
204
225
|
let { ready } = views;
|
|
226
|
+
let held = 0;
|
|
205
227
|
if (invoiceIds) {
|
|
206
228
|
const wanted = new Set(invoiceIds);
|
|
207
229
|
ready = ready.filter((id) => wanted.has(id));
|
|
208
|
-
|
|
230
|
+
held = wanted.size - ready.length;
|
|
231
|
+
if (!ready.length) {
|
|
232
|
+
const reason = invoiceIds.map((id) => views.problems.get(id)?.[0]?.message).find(Boolean);
|
|
233
|
+
throw new FlowError('not_ready', reason
|
|
234
|
+
? `Not sent: ${reason.replace(/\.?$/, '.')} The invoice is on Pending.`
|
|
235
|
+
: 'Only a ready invoice can be sent. Mark it ready first.');
|
|
236
|
+
}
|
|
209
237
|
}
|
|
210
238
|
const { go, waiting } = accountingRead.outcome === 'waiting'
|
|
211
239
|
? { go: [], waiting: ready.slice(0, SEND_BATCH) }
|
|
@@ -229,7 +257,7 @@ export async function sendAllReady(db, organisationId, { accounting = null, work
|
|
|
229
257
|
}
|
|
230
258
|
issued += 1;
|
|
231
259
|
}
|
|
232
|
-
return { issued, queued: accounting ? issued : 0, remaining: Math.max(0, ready.length - SEND_BATCH), waiting: waiting.length };
|
|
260
|
+
return { issued, queued: accounting ? issued : 0, remaining: Math.max(0, ready.length - SEND_BATCH), waiting: waiting.length, held };
|
|
233
261
|
}
|
|
234
262
|
|
|
235
263
|
// Send again: issued invoices that failed go back on the queue. With no ids,
|
package/server/ui/flow.css
CHANGED
|
@@ -387,9 +387,6 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
387
387
|
.fx-tabs button[aria-selected="true"] { color: var(--t-strong); border-bottom-color: var(--t-brand); }
|
|
388
388
|
.fx-tabs__count { padding: 0 7px; border-radius: 999px; background: var(--fx-hover); color: var(--t-dim); font-size: 12px; line-height: 20px; }
|
|
389
389
|
.fx-tabs button[data-tone="danger"] .fx-tabs__count { background: var(--t-danger-bg); color: var(--t-danger); }
|
|
390
|
-
/* Invoices: the Send all ready bar above the Ready list. */
|
|
391
|
-
.fx-sendbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 12px; padding: 12px 16px; border: 1px solid var(--fx-line); border-radius: 8px; background: var(--t-paper); margin-top: 12px; }
|
|
392
|
-
.fx-sendbar[hidden] { display: none; }
|
|
393
390
|
|
|
394
391
|
/* Quiet alerts (server/ui/alerts.js): one slim line under the page header,
|
|
395
392
|
only while something on the page needs this person. */
|
|
@@ -409,9 +406,7 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
409
406
|
.fx-alerts__text { flex-basis: calc(100% - 52px); }
|
|
410
407
|
.fx-alerts__action, .fx-alerts__more { order: 2; }
|
|
411
408
|
}
|
|
412
|
-
.fx-sendbar p { margin: 0; }
|
|
413
409
|
.fx-problem { color: var(--t-danger); }
|
|
414
|
-
.fx-sendbar__actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
415
410
|
.fx-tick-cell { width: 40px; }
|
|
416
411
|
.fx-tick { width: 16px; height: 16px; accent-color: var(--t-brand); cursor: pointer; }
|
|
417
412
|
.fx-search { width: min(320px, 100%); margin-bottom: 8px; }
|
|
@@ -705,26 +700,15 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
705
700
|
.fx-task__none { display: inline-flex; align-items: center; gap: 6px; color: var(--t-warn); }
|
|
706
701
|
.fx-task__none svg { width: 16px; height: 16px; }
|
|
707
702
|
|
|
708
|
-
/* Reconcile: the
|
|
709
|
-
|
|
710
|
-
.fx-rec
|
|
711
|
-
.fx-
|
|
712
|
-
.fx-
|
|
713
|
-
.fx-
|
|
714
|
-
.fx-
|
|
715
|
-
.fx-rec__search { width: 100%; min-width: 0; }
|
|
703
|
+
/* Reconcile (reconcile.js): the list, as Jobs and Invoices; one gap, as an
|
|
704
|
+
invoice opened from Invoices (the pager on top). */
|
|
705
|
+
.fx-rec { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
|
706
|
+
.fx-rec .fx-lucide { width: 16px; height: 16px; }
|
|
707
|
+
.fx-rec__gap { max-width: 1180px; }
|
|
708
|
+
.fx-rec__gap .fx-toolbar .fx-rows__title { margin: 0; }
|
|
709
|
+
.fx-rec__gap .fx-ph__actions .fx-btn, .fx-rec__acts .fx-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
|
716
710
|
.fx-rec__results { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
|
717
|
-
.fx-
|
|
718
|
-
.fx-rec__item:hover { background: var(--fx-hover); }
|
|
719
|
-
.fx-rec__item[aria-current="true"] { border-left-color: var(--t-accent); background: var(--t-s1); }
|
|
720
|
-
.fx-rec__item-top { display: flex; justify-content: space-between; gap: 8px; }
|
|
721
|
-
.fx-rec__item .fx-num { font-family: var(--t-data); font-variant-numeric: tabular-nums; }
|
|
722
|
-
.fx-rec__detail { display: flex; flex-direction: column; gap: 16px; min-width: 0; padding: 20px 24px; }
|
|
723
|
-
.fx-rec__head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
|
|
724
|
-
.fx-rec__head > div { display: grid; gap: 2px; }
|
|
725
|
-
.fx-rec__back { display: none; color: var(--t-accent); text-decoration: none; font-size: 13px; }
|
|
726
|
-
.fx-rec__title { margin: 0; font-family: var(--t-display); font-weight: var(--t-display-weight); color: var(--t-strong); font-size: 24px; }
|
|
727
|
-
.fx-rec__table { display: grid; grid-template-columns: 110px minmax(150px, 1fr) repeat(var(--cols), minmax(170px, 1fr)); border: 1px solid var(--fx-line); border-radius: var(--t-radius); overflow-x: auto; }
|
|
711
|
+
.fx-rec__table { display: grid; grid-template-columns: 110px minmax(150px, 1fr) repeat(var(--cols), minmax(170px, 1fr)); border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); overflow-x: auto; }
|
|
728
712
|
.fx-rec__row { display: contents; }
|
|
729
713
|
.fx-rec__row > span { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border-top: 1px solid var(--fx-line-soft); font-size: 13.5px; color: var(--t-strong); }
|
|
730
714
|
.fx-rec__row:first-child > span { border-top: 0; background: var(--t-s1); }
|
|
@@ -738,8 +722,6 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
738
722
|
[data-mark="same"] > .fx-rec__mark { color: var(--t-success); }
|
|
739
723
|
[data-mark="differs"], [data-mark="differs"] > .fx-rec__mark { color: var(--t-warn); }
|
|
740
724
|
.fx-rec__best { color: var(--t-success); font-size: 12px; font-weight: 700; }
|
|
741
|
-
.fx-rec__foot { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
742
|
-
.fx-rec__foot-acts { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
743
725
|
.fx-rec__cards { display: none; }
|
|
744
726
|
.fx-rec__card { display: flex; flex-direction: column; gap: 6px; padding: 12px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); font-size: 13.5px; color: var(--t-strong); }
|
|
745
727
|
.fx-rec__card.fx-rec__job { border-color: transparent; }
|
|
@@ -747,20 +729,17 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
747
729
|
.fx-rec__card-top { display: flex; justify-content: space-between; gap: 8px; }
|
|
748
730
|
.fx-rec__label { color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
749
731
|
.fx-rec__checks { display: flex; flex-wrap: wrap; gap: 4px 12px; }
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
.fx-rec__back { display: inline; }
|
|
756
|
-
}
|
|
732
|
+
.fx-rec-list tbody tr { cursor: pointer; }
|
|
733
|
+
.fx-rec-list__kind { width: 36px; color: var(--t-dim); }
|
|
734
|
+
.fx-rec-list__mid { width: 72px; text-align: center; white-space: nowrap; }
|
|
735
|
+
.fx-rec__possible { display: inline-flex; align-items: center; gap: 4px; color: var(--t-dim); font-variant-numeric: tabular-nums; }
|
|
736
|
+
.fx-rec__possible[data-state="some"] { color: var(--t-warn); }
|
|
757
737
|
@media (max-width: 820px) {
|
|
758
|
-
.fx-rec__detail { padding: 16px; }
|
|
759
738
|
.fx-rec__table { display: none; }
|
|
760
739
|
.fx-rec__cards { display: flex; flex-direction: column; gap: 12px; }
|
|
761
|
-
.fx-rec__cards .fx-btn, .fx-
|
|
762
|
-
.fx-
|
|
763
|
-
.fx-
|
|
740
|
+
.fx-rec__cards .fx-btn, .fx-rec__gap .fx-ph__actions .fx-btn { min-height: 44px; }
|
|
741
|
+
.fx-rec__gap .fx-ph__actions { width: 100%; }
|
|
742
|
+
.fx-rec__gap .fx-ph__actions .fx-btn { flex: 1 1 auto; }
|
|
764
743
|
}
|
|
765
744
|
.fx-task__custom { margin-top: 24px; }
|
|
766
745
|
.fx-task__custom h3 { margin: 0 0 4px; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
@@ -1293,7 +1272,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1293
1272
|
Narrow, the app bar (60px) sits above it. */
|
|
1294
1273
|
.fx-listhead { position: sticky; top: 0; z-index: 5; margin: -12px calc(-1 * clamp(16px, 4vw, 48px)) 0; padding: 12px clamp(16px, 4vw, 48px) 0; background: var(--t-bg); transition: box-shadow .15s; }
|
|
1295
1274
|
.fx-listhead[data-stuck] { box-shadow: 0 10px 12px -12px rgba(0, 0, 0, .45); }
|
|
1296
|
-
.fx-listhead .fx-sendbar { margin-bottom: 12px; }
|
|
1297
1275
|
.fx-listhead__edge { height: 0; margin-bottom: -20px; }
|
|
1298
1276
|
@media (max-width: 820px) { .fx-listhead { top: 60px; } }
|
|
1299
1277
|
/* On a phone the head would hide too many rows: it scrolls away. */
|
|
@@ -1389,3 +1367,11 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1389
1367
|
}
|
|
1390
1368
|
.fx-xi-count { font-size: 18px; }
|
|
1391
1369
|
.fx-xi-list td.fx-num:not(.fx-strong) { text-align: left; }
|
|
1370
|
+
|
|
1371
|
+
/* A list's own search (view-bar.js findBox): an icon that opens into a box. */
|
|
1372
|
+
.fx-find { display: inline-flex; align-items: center; }
|
|
1373
|
+
.fx-find__button { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; }
|
|
1374
|
+
.fx-find__button .fx-lucide { width: 15px; height: 15px; }
|
|
1375
|
+
.fx-find__input { display: none; width: min(240px, 60vw); height: 32px; padding: 0 10px; }
|
|
1376
|
+
.fx-find--open .fx-find__input { display: block; }
|
|
1377
|
+
.fx-find--open .fx-find__button { display: none; }
|
|
@@ -11,9 +11,13 @@ const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/INVOICE_RULES.
|
|
|
11
11
|
const TEST_LABELS = {
|
|
12
12
|
more_than: 'is more than', at_most: 'is at most', is: 'is', is_not: 'is not', is_one_of: 'is one of',
|
|
13
13
|
is_empty: 'is empty', is_not_empty: 'is not empty', has: 'has', has_not: 'does not have',
|
|
14
|
+
is_not_one_of: 'is not one of', contains: 'contains', does_not_contain: 'does not contain',
|
|
15
|
+
starts_with: 'starts with', does_not_start_with: 'does not start with', has_number: 'has a number', one_word: 'is one word',
|
|
14
16
|
};
|
|
15
|
-
const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft' };
|
|
16
|
-
const NO_VALUE = ['is_empty', 'is_not_empty'];
|
|
17
|
+
const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft', pending: 'Pending, with a note' };
|
|
18
|
+
const NO_VALUE = ['is_empty', 'is_not_empty', 'has_number', 'one_word'];
|
|
19
|
+
const MANY = ['is_one_of', 'is_not_one_of'];
|
|
20
|
+
const TYPED = ['contains', 'does_not_contain', 'starts_with', 'does_not_start_with'];
|
|
17
21
|
|
|
18
22
|
// A rule as one short line: "Job age (days) is more than 90 · Wait for a person".
|
|
19
23
|
export function ruleSummary(rule, meta) {
|
|
@@ -33,7 +37,8 @@ export function ruleSummary(rule, meta) {
|
|
|
33
37
|
const value = Array.isArray(c.value) ? c.value.map((v) => labelOf(field, v)).join(', ') : labelOf(field, c.value);
|
|
34
38
|
return `${field.label} ${test} ${value}`;
|
|
35
39
|
};
|
|
36
|
-
|
|
40
|
+
const then = rule.action.kind === 'pending' ? `Pending: ${rule.action.note}` : ACTION_LABELS[rule.action.kind] ?? rule.action.kind;
|
|
41
|
+
return `${rule.conditions.map(part).join(' and ')} · ${then}`;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export function rulesSection(ui, title = 'Rules') {
|
|
@@ -100,9 +105,19 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
100
105
|
const box = numberBox(value, 'Value');
|
|
101
106
|
valueHost.append(box);
|
|
102
107
|
readValue = () => ({ value: Number(box.value) });
|
|
103
|
-
} else if (test === '
|
|
104
|
-
const
|
|
105
|
-
|
|
108
|
+
} else if (TYPED.includes(test) || (field.kind === 'text' && !MANY.includes(test) && !options(field).length)) {
|
|
109
|
+
const box = h('input', { class: 'fx-input', type: 'text', maxlength: '200', value: Array.isArray(value) ? value[0] ?? '' : value ?? '', 'aria-label': 'Value' });
|
|
110
|
+
valueHost.append(box);
|
|
111
|
+
readValue = () => ({ value: box.value });
|
|
112
|
+
} else if (MANY.includes(test)) {
|
|
113
|
+
const picked = Array.isArray(value) ? value : [];
|
|
114
|
+
const chosen = new Set(picked.map((v) => String(v).toLowerCase()));
|
|
115
|
+
// A value saved earlier that is no longer on the menu stays ticked.
|
|
116
|
+
const known = new Set(options(field).map((o) => String(o.value).toLowerCase()));
|
|
117
|
+
// Ticked values come first, so a long menu shows what the rule holds.
|
|
118
|
+
const list = [...picked.filter((v) => !known.has(String(v).toLowerCase())).map((v) => ({ value: v, label: v })), ...options(field)]
|
|
119
|
+
.sort((x, y) => Number(chosen.has(String(y.value).toLowerCase())) - Number(chosen.has(String(x.value).toLowerCase())));
|
|
120
|
+
const boxes = list.map((o) => {
|
|
106
121
|
const box = h('input', { type: 'checkbox', value: o.value, checked: chosen.has(String(o.value).toLowerCase()) });
|
|
107
122
|
return h('label', { class: 'fx-multi__option' }, box, h('span', {}, o.label));
|
|
108
123
|
});
|
|
@@ -112,10 +127,6 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
112
127
|
const select = choiceSelect(field, Array.isArray(value) ? value[0] : value, 'Value');
|
|
113
128
|
valueHost.append(select);
|
|
114
129
|
readValue = () => ({ value: select.value });
|
|
115
|
-
} else {
|
|
116
|
-
const box = h('input', { class: 'fx-input', type: 'text', value: value ?? '', 'aria-label': 'Value' });
|
|
117
|
-
valueHost.append(box);
|
|
118
|
-
readValue = () => ({ value: box.value });
|
|
119
130
|
}
|
|
120
131
|
}
|
|
121
132
|
|
|
@@ -144,6 +155,15 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
144
155
|
const action = h('select', { class: 'fx-select', 'aria-label': 'Then' },
|
|
145
156
|
Object.entries(ACTION_LABELS).map(([value, label]) => h('option', { value }, label)));
|
|
146
157
|
action.value = draft?.action.kind ?? 'wait';
|
|
158
|
+
// A pending rule's note: what a person must fix. It shows on the draft.
|
|
159
|
+
const note = h('input', {
|
|
160
|
+
class: 'fx-input', maxlength: '200', value: draft?.action.note ?? '', 'aria-label': 'Note',
|
|
161
|
+
placeholder: 'For example: Put the practitioner in the job, then check the Reference.',
|
|
162
|
+
});
|
|
163
|
+
const noteField = h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Note on the draft'), note);
|
|
164
|
+
const showNote = () => { noteField.hidden = action.value !== 'pending'; };
|
|
165
|
+
action.addEventListener('change', showNote);
|
|
166
|
+
showNote();
|
|
147
167
|
let editors = [];
|
|
148
168
|
const list = h('div', { class: 'fx-rule__conditions' });
|
|
149
169
|
const addCondition = (start) => {
|
|
@@ -174,9 +194,10 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
174
194
|
title: rule ? 'Edit rule' : 'New rule',
|
|
175
195
|
body: h('div', { class: 'fx-fields fx-rule__dialog' },
|
|
176
196
|
label('Name', name),
|
|
177
|
-
label('When', h('span', { class: 'fx-dim' }, 'A job is ready to invoice and
|
|
197
|
+
label('When', h('span', { class: 'fx-dim' }, 'A job is ready to invoice. A pending rule also checks its draft and the Reference.')),
|
|
178
198
|
label('If all of these match', list, more),
|
|
179
199
|
label('Then', action),
|
|
200
|
+
noteField,
|
|
180
201
|
remove),
|
|
181
202
|
confirm: rule ? 'Save' : 'Add rule',
|
|
182
203
|
});
|
|
@@ -186,7 +207,11 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
186
207
|
return draw();
|
|
187
208
|
}
|
|
188
209
|
if (!data) return null;
|
|
189
|
-
const payload = {
|
|
210
|
+
const payload = {
|
|
211
|
+
name: name.value,
|
|
212
|
+
conditions: editors.map((e) => e.read()),
|
|
213
|
+
action: action.value === 'pending' ? { kind: 'pending', note: note.value } : { kind: action.value },
|
|
214
|
+
};
|
|
190
215
|
const body = rule
|
|
191
216
|
? await send(`/api/invoicing/rules/${rule.id}`, 'PUT', payload)
|
|
192
217
|
: await send('/api/invoicing/rules', 'POST', payload);
|
|
@@ -219,7 +244,7 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
219
244
|
add?.addEventListener('click', () => edit(null));
|
|
220
245
|
section.replaceChildren(
|
|
221
246
|
h('h2', { class: 'fx-set__title' }, title),
|
|
222
|
-
h('p', { class: 'fx-set__hint', style: { margin: '0 0 4px' } }, '
|
|
247
|
+
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.'),
|
|
223
248
|
...(rules.length ? rules.map(rowOf) : [h('p', { class: 'fx-dim' }, 'No rules. Every ready job is drafted.')]),
|
|
224
249
|
h('div', { class: 'fx-rule__footer' }, add,
|
|
225
250
|
h('a', { class: 'fx-dim', href: DOCS, target: '_blank', rel: 'noopener' }, 'How rules work')));
|