@arcforgelabs/flow 2026.10.41 → 2026.10.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.41",
3
+ "version": "2026.10.43",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.41",
9
+ "version": "2026.10.43",
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.41",
3
+ "version": "2026.10.43",
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 = [
@@ -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,
@@ -42,6 +42,7 @@ export const SENTRY_SETUP = {
42
42
  fields: [
43
43
  { name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
44
44
  { name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
45
+ { name: 'SENTRY_CLIENT', label: 'Client', help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
45
46
  { name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
46
47
  { name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
47
48
  ],
@@ -60,7 +61,12 @@ export function readSentrySettings(env) {
60
61
  if (!/^\d*\.?\d+$/.test(rate || '0') || !(tracesSampleRate >= 0 && tracesSampleRate <= 1)) {
61
62
  throw new Error('SENTRY_TRACES_SAMPLE_RATE must be a number from 0 to 1, such as 0.2.');
62
63
  }
63
- return { dsn, environment, feedback, tracesSampleRate };
64
+ const client = String(env.SENTRY_CLIENT ?? '').trim();
65
+ if (client.length > 60 || /[\r\n]/.test(client)) throw new Error('SENTRY_CLIENT must be one short line.');
66
+ // Every event says which product and, when set, which client it is from,
67
+ // so one support desk can serve many installations.
68
+ const tags = client ? { product: 'flow', client } : { product: 'flow' };
69
+ return { dsn, environment, feedback, tracesSampleRate, tags };
64
70
  }
65
71
 
66
72
  // No trace for the host's health check: it is not a person waiting.
@@ -92,6 +98,7 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
92
98
  dsn: settings.dsn,
93
99
  environment: settings.environment,
94
100
  release: release ?? undefined,
101
+ initialScope: { tags: settings.tags },
95
102
  // Flow sends what it logs; no request bodies, cookies or IP addresses.
96
103
  sendDefaultPii: false,
97
104
  // A request the browser traced keeps the browser's choice, so a
@@ -132,6 +139,7 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
132
139
  environment: settings.environment,
133
140
  release,
134
141
  feedback: settings.feedback,
142
+ tags: settings.tags,
135
143
  tracesSampleRate,
136
144
  script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : SENTRY_BROWSER,
137
145
  };
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
51
51
  <div id="toasts" class="fx-toasts" aria-live="polite"></div>
52
52
  </div>`;
53
53
 
54
- export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list', '/custom-fields'];
54
+ export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/custom-fields'];
55
55
 
56
56
  export function appPage({ defaultFamily }) {
57
57
  return renderPage({
package/server/ui/app.js CHANGED
@@ -10,7 +10,7 @@ import { getAppearance, onAppearanceChange, setAppearance, loadFonts } from '/as
10
10
  import { flowClerkAppearance } from '/assets/ui/clerk-appearance.js';
11
11
  import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/assets/theme/themes.js';
12
12
  import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
13
- import { automationSection } from '/assets/ui/automations.js';
13
+ import { automationSection, renderAutomations } from '/assets/ui/automations.js';
14
14
  import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
15
15
  import { renderPriceList } from '/assets/ui/price-list.js';
16
16
  import { renderPriceReconcile } from '/assets/ui/price-reconcile.js';
@@ -21,6 +21,7 @@ import { renderXeroImport } from '/assets/ui/xero-import.js';
21
21
  import { startMonitoring } from '/assets/ui/monitoring.js';
22
22
  import { openSearch } from '/assets/ui/search.js';
23
23
  import { alertStrip } from '/assets/ui/alerts.js';
24
+ import { named, startTips } from '/assets/ui/tip.js';
24
25
 
25
26
  const $ = (id) => document.getElementById(id);
26
27
 
@@ -164,14 +165,28 @@ function holdButton(button, ms) {
164
165
 
165
166
  // ── Shared pieces ──────────────────────────────────────────────────────────
166
167
 
167
- function pageHeader(title, { subtitle, actions = [] } = {}) {
168
+ // The page's name and nothing under it: the page shows what it is for.
169
+ // `count` is a live number beside the name (2 to check); `help` is a docs
170
+ // link, an icon with its name as a tip.
171
+ function pageHeader(title, { count = null, help = null, actions = [] } = {}) {
168
172
  return h('header', { class: 'fx-ph' },
169
173
  h('div', { class: 'fx-ph__text' },
170
174
  h('h1', { class: 'fx-title' }, title),
171
- subtitle ? h('p', { class: 'fx-ph__sub' }, subtitle) : null),
175
+ count != null ? h('span', { class: 'fx-ph__count' }, count) : null,
176
+ help ? helpLink(help) : null),
172
177
  actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
173
178
  }
174
179
 
180
+ // A docs link as a small "?" icon whose tip names it (How sending works).
181
+ function helpLink({ href, label }) {
182
+ return named(h('a', { class: 'fx-help', href, target: '_blank', rel: 'noopener' }, lucide(h, 'circle-help')), label);
183
+ }
184
+
185
+ // A settings section's heading, with its docs link beside it.
186
+ function sectionTitle(text, help = null) {
187
+ return h('h2', { class: 'fx-set__title' }, text, help ? helpLink(help) : null);
188
+ }
189
+
175
190
  function notice(text, tone = 'info', title = null) {
176
191
  return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
177
192
  title ? h('strong', {}, title) : null,
@@ -235,7 +250,8 @@ const VIEWS = {
235
250
  jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
236
251
  invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
237
252
  reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
238
- 'invoicing-settings': { label: 'Invoice rules', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
253
+ 'invoicing-settings': { label: 'Rules', icon: 'rules', group: 'Settings', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
254
+ automations: { label: 'Automations', icon: 'bolt', group: 'Settings', render: (main) => renderAutomations(main, ui()) },
239
255
  'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
240
256
  'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
241
257
  people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
@@ -252,6 +268,9 @@ const VIEWS = {
252
268
  const ICON_PATHS = {
253
269
  jobs: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="11" rx="1"/><rect x="17" y="4" width="4" height="14" rx="1"/>',
254
270
  invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
271
+ draft: '<path d="M14 3H6v18h5M14 3l4 4v3M14 3v4h4"/><path d="M13 21v-2.5l6-6 2.5 2.5-6 6z"/>',
272
+ bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
273
+ rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
255
274
  reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
256
275
  people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
257
276
  tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
@@ -292,11 +311,11 @@ function drawMenu() {
292
311
  const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the sidebar' : 'Hide the sidebar');
293
312
  toggle.setAttribute('aria-expanded', String(open));
294
313
  toggle.setAttribute('aria-label', label);
295
- toggle.title = label;
314
+ toggle.dataset.tip = label;
296
315
  // Folded, a page's name shows when the pointer rests on its icon.
297
316
  for (const link of document.querySelectorAll('[data-nav]')) {
298
- if (folded && !NARROW.matches) link.title = link.dataset.label;
299
- else link.removeAttribute('title');
317
+ if (folded && !NARROW.matches) Object.assign(link.dataset, { tip: link.dataset.label, tipSide: 'right' });
318
+ else { delete link.dataset.tip; delete link.dataset.tipSide; }
300
319
  }
301
320
  }
302
321
 
@@ -403,7 +422,7 @@ function navigate(event) {
403
422
  // The shared pieces a view module (invoicing.js) builds with. Its `go`
404
423
  // takes a path, or a link's click event.
405
424
  const ui = () => ({
406
- h, flow, toast, notice, pageHeader, dialog, me, holdButton,
425
+ h, flow, toast, notice, pageHeader, sectionTitle, dialog, me, holdButton, tileCard, crumbs,
407
426
  go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
408
427
  });
409
428
 
@@ -641,9 +660,9 @@ async function renderIntegrations(main) {
641
660
  const notices = h('div', { class: 'fx-fields' });
642
661
  const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
643
662
  main.append(
644
- h('header', { class: 'fx-ph' }, h('div', { class: 'fx-ph__text' }, h('h1', { class: 'fx-title' }, 'Integrations'))),
663
+ pageHeader('Integrations'),
645
664
  alertStrip(ui(), 'integrations').element,
646
- h('div', { class: 'fx-int-bar' }, tabs, search),
665
+ h('div', { class: 'fx-int-bar' }, tabs, h('div', { class: 'fx-row' }, category, search)),
647
666
  notices, content);
648
667
  let body;
649
668
  try {
@@ -655,13 +674,14 @@ async function renderIntegrations(main) {
655
674
  const canChange = Boolean(me.ownerActions?.allowed);
656
675
  notices.replaceChildren(...[
657
676
  canChange ? null : notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
658
- body.secretsKey ? null : notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
677
+ body.secretsKey ? null : notice('Flow cannot store a client secret. Set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
659
678
  ].filter(Boolean));
660
679
  const list = [...body.integrations].sort((a, b) => a.name.localeCompare(b.name));
661
680
  const connected = (i) => i.enabled;
662
681
 
663
682
  const draw = () => {
664
- tabs.replaceChildren(...[['all', 'All integrations'], ['yours', 'Your integrations']].map(([id, label]) => h('button', {
683
+ category.hidden = state.tab !== 'all';
684
+ tabs.replaceChildren(...[['all', 'All'], ['yours', 'Connected']].map(([id, label]) => h('button', {
665
685
  type: 'button', role: 'tab', class: 'fx-seg__item', 'aria-selected': String(state.tab === id), 'aria-checked': String(state.tab === id),
666
686
  onclick: () => { state.tab = id; draw(); },
667
687
  }, label)));
@@ -672,7 +692,7 @@ async function renderIntegrations(main) {
672
692
  const yours = list.filter(connected).filter(matches);
673
693
  sections.push(h('section', { class: 'fx-int-section-list' },
674
694
  yours.length ? h('div', { class: 'fx-int-grid' }, yours.map(integrationTile))
675
- : h('p', { class: 'fx-empty' }, q ? 'No integration matches.' : 'Nothing is connected yet. Pick one under All integrations.')));
695
+ : h('p', { class: 'fx-empty' }, q ? 'No integration matches.' : 'Nothing is connected yet. Pick one under All.')));
676
696
  } else {
677
697
  // Recommended: what is not connected yet, once the catalogue is long
678
698
  // enough that a short row helps (with a few, it would repeat the grid).
@@ -684,7 +704,7 @@ async function renderIntegrations(main) {
684
704
  }
685
705
  const all = list.filter(matches).filter((i) => state.category === 'all' || CATALOGUE[i.id]?.category === state.category);
686
706
  sections.push(h('section', { class: 'fx-int-section-list' },
687
- h('div', { class: 'fx-int-heading-row' }, h('h2', { class: 'fx-int-heading' }, 'All integrations'), category),
707
+ recommended.length ? h('h2', { class: 'fx-int-heading' }, 'All') : null,
688
708
  all.length ? h('div', { class: 'fx-int-grid' }, all.map(integrationTile)) : h('p', { class: 'fx-empty' }, 'No integration matches.')));
689
709
  }
690
710
  content.replaceChildren(...sections);
@@ -695,17 +715,39 @@ async function renderIntegrations(main) {
695
715
  }
696
716
 
697
717
  function integrationTile(integration) {
698
- const href = `/integrations/${integration.id}`;
699
718
  const entry = CATALOGUE[integration.id];
700
719
  const on = integration.enabled;
701
720
  const attention = ['failed', 'restart_needed'].includes(integration.state);
702
- return h('a', { class: 'fx-int-card', href, onclick: navigate },
721
+ return tileCard({
722
+ href: `/integrations/${integration.id}`,
723
+ mark: integrationLogo(integration),
724
+ title: integration.name,
725
+ status: on ? { label: STATE_LABEL[integration.state] ?? integration.state, tone: STATE_TONE[integration.state] } : null,
726
+ blurb: entry?.blurb,
727
+ action: attention ? 'Fix' : on ? 'Manage' : '+ Connect',
728
+ });
729
+ }
730
+
731
+ // One card in a grid of choices (Integrations, Rules): its mark (a node, or
732
+ // an ICON_PATHS name), the name, a status pill, two lines at most (only when
733
+ // they help), and the action. The whole card is the link.
734
+ function tileCard({ href, mark, title, status, blurb, action = 'Manage' }) {
735
+ const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, icon(mark)) : mark;
736
+ return h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: navigate },
703
737
  h('header', { class: 'fx-int-card__head' },
704
- integrationLogo(integration),
705
- h('h3', {}, integration.name),
706
- on ? h('span', { class: 'fx-status', 'data-tone': STATE_TONE[integration.state] ?? 'mute' }, STATE_LABEL[integration.state] ?? integration.state) : null),
707
- h('p', { class: 'fx-int-card__blurb' }, entry?.blurb ?? ''),
708
- h('span', { class: 'fx-int-card__btn' }, attention ? 'Fix' : on ? 'Manage' : '+ Connect'));
738
+ logo,
739
+ h('h3', {}, title),
740
+ status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null),
741
+ blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
742
+ h('span', { class: 'fx-int-card__btn' }, action));
743
+ }
744
+
745
+ // The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
746
+ function crumbs(...trail) {
747
+ const here = trail.pop();
748
+ return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
749
+ ...trail.flatMap(([href, label]) => [h('a', { href, onclick: navigate }, label), h('span', { 'aria-hidden': 'true' }, '›')]),
750
+ h('span', {}, here));
709
751
  }
710
752
 
711
753
  // One integration's page (/integrations/<id>): its logo and maker, Connect
@@ -842,8 +884,7 @@ async function renderIntegration(main, id) {
842
884
  busy ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : null, busy ? 'Connecting' : 'Connect');
843
885
 
844
886
  host.replaceChildren(
845
- h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
846
- h('a', { href: '/integrations', onclick: navigate }, 'Integrations'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, current.name)),
887
+ crumbs(['/integrations', 'Integrations'], current.name),
847
888
  h('header', { class: 'fx-int-hero' },
848
889
  integrationLogo(current, 'xl'),
849
890
  h('div', { class: 'fx-int-hero__text' },
@@ -858,7 +899,7 @@ async function renderIntegration(main, id) {
858
899
  h('div', { class: 'fx-int-page__main' }, ...[
859
900
  alerts.element,
860
901
  !canChange ? notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.') : null,
861
- !body.secretsKey ? notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
902
+ !body.secretsKey ? notice('Flow cannot store a client secret. Set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
862
903
  current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
863
904
  current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
864
905
  current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
@@ -1103,7 +1144,7 @@ function renderAccount(main) {
1103
1144
  // in it at once.
1104
1145
  function renderAppearance(main) {
1105
1146
  main.append(
1106
- pageHeader('Appearance', { subtitle: 'Saved with your account, on every device.' }),
1147
+ pageHeader('Appearance'),
1107
1148
  h('div', { class: 'fx-appearance', id: 'appearance' }));
1108
1149
  drawAppearance();
1109
1150
  }
@@ -1169,13 +1210,16 @@ function drawAppearance() {
1169
1210
  (p) => chooseAppearance({ accent: p.hex ? p.id : null }),
1170
1211
  (p) => p.hex
1171
1212
  ? h('button', { type: 'button', class: 'fx-swatch', title: p.label, 'aria-label': p.label, style: { background: p.hex } })
1172
- : h('button', { type: 'button', class: 'fx-swatch fx-swatch--default', title: `${active.label}'s own`, 'aria-label': `${active.label}'s own accent` },
1173
- h('span', { 'aria-hidden': 'true', style: { background: `linear-gradient(135deg, ${active.swatches[mode].primary} 50%, ${active.swatches[mode].accent} 50%)` } })));
1213
+ // The design system's own accent, named "Default" on the swatch: a
1214
+ // click puts it back.
1215
+ : h('button', { type: 'button', class: 'fx-swatch fx-swatch--default', 'aria-label': `Default: ${active.label}'s own accent` },
1216
+ h('span', { class: 'fx-swatch__dot', 'aria-hidden': 'true', style: { background: `linear-gradient(135deg, ${active.swatches[mode].primary} 50%, ${active.swatches[mode].accent} 50%)` } }),
1217
+ 'Default'));
1174
1218
  accents.classList.add('fx-swatches');
1175
1219
  const custom = h('label', { class: 'fx-swatch-custom' },
1176
1220
  h('input', { type: 'color', 'aria-label': 'Custom accent colour', value: customHex ?? '#0d4e5f', onchange: (event) => chooseAppearance({ accent: event.target.value }) }),
1177
1221
  'Custom');
1178
- const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label : `${active.label}'s own`;
1222
+ const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label : `the default, ${active.label}'s own`;
1179
1223
 
1180
1224
  host.replaceChildren(h('div', { class: 'fx-settings' },
1181
1225
  settingRow({ label: 'Mode', hint: 'Auto follows this device.', control: modeGroup }),
@@ -1230,7 +1274,7 @@ function drawModeToggles() {
1230
1274
  button.innerHTML = dark ? SUN : MOON;
1231
1275
  if (!button.classList.contains('fx-btn--icon')) button.append(` ${dark ? 'Light' : 'Dark'}`);
1232
1276
  button.setAttribute('aria-label', label);
1233
- button.title = label;
1277
+ button.dataset.tip = label;
1234
1278
  }
1235
1279
  }
1236
1280
 
@@ -1291,4 +1335,5 @@ async function start() {
1291
1335
  await renderShell();
1292
1336
  }
1293
1337
 
1338
+ startTips();
1294
1339
  start();
@@ -1,6 +1,6 @@
1
1
  // Automation rows (server/core/automations.js, /api/invoicing/automations).
2
2
  // Each automation shows where its trigger lives: the work system's ready rule
3
- // on that integration's page, the draft rule on Settings, Invoicing. One row
3
+ // on that integration's page, the invoicing ones on Settings, Automations. One row
4
4
  // for each: its name, one short line, and its switch, plus a row for each of
5
5
  // its settings. A change saves at once. Everyone can read them; an owner or
6
6
  // admin changes them. The caller passes in app.js's shared pieces as `ui`.
@@ -86,11 +86,17 @@ export function automationSection(ui, place, title) {
86
86
  (async () => {
87
87
  try {
88
88
  const { automations } = await flow(`/api/invoicing/automations?place=${encodeURIComponent(place)}`);
89
- section.replaceChildren(h('h2', { class: 'fx-set__title' }, title), ...automations.flatMap(rowsOf),
90
- h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How automations work')));
89
+ section.replaceChildren(ui.sectionTitle(title, { href: DOCS, label: 'How automations work' }), ...automations.flatMap(rowsOf));
91
90
  } catch (error) {
92
91
  section.replaceChildren(h('h2', { class: 'fx-set__title' }, title), notice(error.message, 'danger'));
93
92
  }
94
93
  })();
95
94
  return section;
96
95
  }
96
+
97
+ // Settings, Automations (/automations): the invoicing automations. A work
98
+ // system's own automations stay on its integration page.
99
+ export function renderAutomations(main, ui) {
100
+ const { h, pageHeader } = ui;
101
+ main.append(pageHeader('Automations'), h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui, 'invoicing', 'Invoicing')));
102
+ }