@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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.40",
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.40",
9
+ "version": "2026.10.42",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.40",
3
+ "version": "2026.10.42",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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, each ready) }
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
- const held = problems.find((p) => p.code === 'job_review');
546
- if (held) throw new FlowError('not_ready', `${held.message} Nothing was approved.`);
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]) => ({ key, label: f.label, kind: f.kind, options: f.options ?? null, tests: TESTS[f.kind] })),
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 (['is_empty', 'is_not_empty'].includes(raw.test)) return out;
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 === 'is_one_of') {
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
- return { name, enabled, conditions: raw.conditions.map(readCondition), action: { kind: raw.action.kind } };
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 = [
@@ -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}
@@ -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). A draft with a problem is
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 = await db.select().from(billingObligations)
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
- if (ready.length !== wanted.size) throw new FlowError('not_ready', 'Only a ready invoice can be sent. Mark it ready first.');
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,
@@ -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 queue left, one job and its possible invoices right. */
709
- .fx-rec { display: grid; grid-template-columns: 280px minmax(0, 1fr); border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); background: var(--t-paper); min-height: 420px; }
710
- .fx-rec > .fx-empty, .fx-rec > .fx-dim { grid-column: 1 / -1; }
711
- .fx-rec__queue { display: flex; flex-direction: column; border-right: 1px solid var(--fx-line); max-height: 75vh; overflow-y: auto; }
712
- .fx-rec__tabs { display: flex; flex-wrap: wrap; gap: 6px; padding: 12px 16px; border-bottom: 1px solid var(--fx-line); }
713
- .fx-rec__tab { display: inline-flex; align-items: center; min-height: 32px; padding: 0 10px; border: 1px solid var(--fx-line); border-radius: 999px; color: var(--t-text); font-size: 12.5px; font-weight: 600; text-decoration: none; }
714
- .fx-rec__tab[aria-selected="true"] { border-color: var(--t-primary); background: var(--t-primary); color: var(--t-on-primary); }
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-rec__item { display: grid; gap: 3px; min-height: 44px; padding: 12px 16px; border-bottom: 1px solid var(--fx-line-soft); border-left: 3px solid transparent; color: var(--t-text); text-decoration: none; }
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
- @media (max-width: 1100px) {
751
- .fx-rec { grid-template-columns: minmax(0, 1fr); min-height: 0; }
752
- .fx-rec__queue { border-right: 0; max-height: none; }
753
- .fx-rec--open .fx-rec__queue { display: none; }
754
- .fx-rec:not(.fx-rec--open) .fx-rec__detail { display: none; }
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-rec__foot .fx-btn { min-height: 44px; }
762
- .fx-rec__foot-acts { width: 100%; }
763
- .fx-rec__foot-acts .fx-btn { flex: 1 1 auto; }
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
- return `${rule.conditions.map(part).join(' and ')} · ${ACTION_LABELS[rule.action.kind] ?? rule.action.kind}`;
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 === 'is_one_of') {
104
- const chosen = new Set((Array.isArray(value) ? value : []).map((v) => String(v).toLowerCase()));
105
- const boxes = options(field).map((o) => {
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 has no invoice.')),
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 = { name: name.value, conditions: editors.map((e) => e.read()), action: { kind: action.value } };
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' } }, 'A ready job waits, or is never drafted, when a rule matches.'),
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')));