@arcforgelabs/flow 2026.10.36 → 2026.10.37

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.
@@ -76,66 +76,82 @@ const capital = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
76
76
  const sentence = (text) => capital(String(text).trim().replace(/\.?$/, '.'));
77
77
 
78
78
  // One ledger reason (server/core/ledger.js) as a fix: a short label, what is
79
- // wrong, and what to do.
80
- function reasonFix(reason, acct) {
81
- const fix = (label, todo) => ({ label, text: sentence(reason), todo });
79
+ // wrong, what to do, and where to go to do it (`go`, see GO). A job's fix
80
+ // speaks of Flow's own invoice only. How that invoice stands in an
81
+ // accounting system is the invoice's business, on the invoice page.
82
+ function reasonFix(reason) {
83
+ const fix = (label, todo, go = null) => ({ label, text: sentence(reason), todo, go });
82
84
  if (reason === 'the accounting system was not checked') {
83
- return { label: `Not checked in ${acct}`, text: `Flow did not check ${acct} for an invoice for this job yet.`, todo: `Wait for the next check, a few minutes. If it stays, check ${acct} under Settings, Integrations.` };
85
+ return { label: 'Not checked yet', text: 'Flow did not check for an invoice for this job yet.', todo: 'Wait for the next check, a few minutes. If it stays, check Settings, Integrations.', go: 'integrations' };
84
86
  }
85
87
  if (reason === 'a line does not map to a price item') {
86
- return { label: 'No price code', text: 'A line has a code that is not in the price list.', todo: 'Add the code to the price list, or correct the code in the work system.' };
88
+ return { label: 'No price code', text: 'A line has a code that is not in the price list.', todo: 'Add the code to the price list, or correct the code in the work system.', go: 'price-list' };
87
89
  }
88
90
  if (reason === 'the job has no customer contact') {
89
- return { label: 'Client not linked', text: 'The client is not linked to a customer in Flow.', todo: 'Link the client to a customer in Flow.' };
91
+ return { label: 'Client not linked', text: 'The client is not linked to a customer in Flow.', todo: 'Link the client to a customer in Flow.', go: null };
90
92
  }
91
93
  if (reason === 'the work system report is out of date') {
92
- return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.' };
94
+ return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.', go: 'integrations' };
95
+ }
96
+ if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Open the invoice to see what changed. Fix the job in the work system, or adopt its changes on the draft.', 'invoice');
97
+ if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.', 'work');
98
+ if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.', 'work');
99
+ // The matcher found invoices that may be this job's, but no exact link.
100
+ // Flow holds the job so that it is not invoiced two times.
101
+ if (/^the accounting evidence is ambiguous/.test(reason)) {
102
+ return { label: 'Invoice unclear', text: 'Flow cannot tell if this job has an invoice already.', todo: 'Check the invoice number on the job in the work system. Flow holds the job so it is not invoiced two times.', go: 'work' };
103
+ }
104
+ if (/^the accounting evidence is conflict/.test(reason)) {
105
+ return { label: 'Invoice link changed', text: 'A second invoice now answers for this job, or its invoice is gone.', todo: 'Check the invoice number on the job in the work system. Void an extra invoice.', go: 'work' };
106
+ }
107
+ if (/^invoice .* was (voided|deleted)$/.test(reason)) {
108
+ const voided = /voided$/.test(reason);
109
+ return { label: `Invoice ${voided ? 'voided' : 'deleted'}`, text: `The invoice for this job was ${voided ? 'voided' : 'deleted'}.`, todo: 'Make a new invoice, or close the job.', go: 'invoice' };
110
+ }
111
+ if (/^the accounting system reports /.test(reason)) {
112
+ return { label: 'May be invoiced', text: 'Flow found an invoice that may be for this job.', todo: 'Check the invoice number on the job in the work system.', go: 'work' };
93
113
  }
94
- if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Open the invoice to see what changed. Fix the job in the work system, or adopt its changes on the draft.');
95
- if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.');
96
- if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.');
97
- if (/^the accounting evidence is ambiguous/.test(reason)) return fix(`${acct} match unclear`, `Find the invoice for this job in ${acct}, then link it.`);
98
- if (/^the accounting evidence is conflict/.test(reason)) return fix(`Two ${acct} invoices`, `Find the right invoice for this job in ${acct}. Void the other.`);
99
- if (/^invoice .* was (voided|deleted)$/.test(reason)) return fix(`Invoice ${/voided$/.test(reason) ? 'voided' : 'deleted'}`, 'Make a new invoice, or close the job.');
100
- if (/^the accounting system reports /.test(reason)) return fix(`Already in ${acct}`, `Check the invoice in ${acct}. Link it to this job if it is the right one.`);
101
114
  if (/^the job is held$/.test(reason)) return fix('On hold', 'Release the hold when the job can be invoiced.');
102
- if (/^the job is review$/.test(reason)) return fix('Needs review', 'Check the job and its invoice.');
115
+ if (/^the job is review$/.test(reason)) return fix('Needs review', 'Check the job and its invoice.', 'invoice');
103
116
  if (reason === 'held') return fix('On hold', 'Release the hold when the job can be invoiced.');
104
- return fix('Needs review', 'Check the job and its invoice.');
117
+ return fix('Needs review', 'Check the job and its invoice.', 'invoice');
105
118
  }
106
119
 
107
- // One draft problem (server/core/sending.js, and the accounting system's) as a fix.
108
- function draftFix(problem, acct) {
120
+ // One problem on the job's invoice (server/core/sending.js, and an accounting
121
+ // system's) as a job fix. Flow's own invoice problems keep their words. A
122
+ // problem an accounting system found stays on the invoice page; the job only
123
+ // says that its invoice needs a fix.
124
+ function draftFix(problem) {
109
125
  const code = problem.code ?? '';
110
- const fix = (label, todo) => ({ label, text: sentence(problem.message ?? label), todo });
126
+ const fix = (label, todo) => ({ label, text: sentence(problem.message ?? label), todo, go: 'invoice' });
111
127
  if (code === 'no_lines') return fix('No lines', 'Open the invoice and add the lines.');
112
128
  if (code === 'no_amount') return fix('Total is $0', 'Open the invoice and correct the amounts.');
113
129
  if (code === 'line_no_item') return fix('No price code', 'Open the invoice and choose a price item for each line.');
114
- if (code.endsWith('_item_missing')) return fix(`Item not in ${acct}`, `Add the item in ${acct}, then sync.`);
115
- if (code.endsWith('_contact_missing')) return fix(`Client not in ${acct}`, `Add the client in ${acct}, then sync.`);
116
- if (code.endsWith('_contact_ambiguous')) return fix(`Two ${acct} clients`, `Merge or rename the clients in ${acct}, then sync.`);
117
- if (code.includes('earlier_version')) return fix(`Old copy in ${acct}`, 'Open the invoice for the steps.');
118
- if (code.startsWith('send_') || code.endsWith('_push_failed') || code.endsWith('_outcome_unknown')) return fix('Not sent', 'Open the invoice and send it again.');
119
- return fix('Invoice problem', 'Open the invoice to correct it.');
130
+ if (code.startsWith('send_') || code.endsWith('_push_failed') || code.endsWith('_outcome_unknown')) {
131
+ return { label: 'Not sent', text: 'The invoice for this job was not sent.', todo: 'Open the invoice and send it again.', go: 'invoice' };
132
+ }
133
+ return { label: 'Invoice problem', text: 'The invoice for this job needs a fix before it can be sent.', todo: 'Open the invoice for the steps.', go: 'invoice' };
120
134
  }
121
135
 
122
- // What a person must fix on a job, in order: [{ label, text, todo }]. Empty
123
- // unless invoiceStage is Problem. `accountingName` names the accounting
124
- // system (for example Xero).
125
- export function fixes(o, { accountingName = null } = {}) {
136
+ // Where a fix's link goes: Flow's invoice, the job in the work system, or a
137
+ // Flow settings page (jobs.js draws the link).
138
+ export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations'];
139
+
140
+ // What a person must fix on a job, in order: [{ label, text, todo, go }].
141
+ // Empty unless invoiceStage is Problem.
142
+ export function fixes(o) {
126
143
  if (invoiceStage(o) !== 'problem') return [];
127
- const acct = accountingName ?? 'the accounting system';
128
144
  const found = [];
129
- if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r, acct)));
145
+ if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
130
146
  if (o.view === 'ready') {
131
147
  found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
132
- label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Invoicing, Rules.',
148
+ label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Invoice rules.', go: 'invoicing-settings',
133
149
  })));
134
150
  }
135
151
  if (o.invoiceStatus === 'void') {
136
- found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.' });
152
+ found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
137
153
  }
138
- found.push(...(o.invoiceProblems ?? []).map((p) => draftFix(p, acct)));
154
+ found.push(...(o.invoiceProblems ?? []).map((p) => draftFix(p)));
139
155
  return found;
140
156
  }
141
157
 
@@ -146,6 +162,14 @@ export function problemSummary(found) {
146
162
  return labels.length > 1 ? `${labels[0]} +${labels.length - 1}` : labels[0];
147
163
  }
148
164
 
165
+ // A system's own record id (a UUID) is not a case number a person reads.
166
+ // Search still finds the job by it (job-board.js matches).
167
+ const RECORD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
168
+ export function caseNumber(job) {
169
+ const ref = job.caseRef ?? job.sourceJobRef ?? null;
170
+ return ref && !RECORD_ID.test(ref) ? ref : null;
171
+ }
172
+
149
173
  // Does a job match the search text? Case, client, practitioner or invoice
150
174
  // number; the case is not shown on the board, but a person can find it.
151
175
  export function matches(job, q) {
package/server/ui/jobs.js CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { formatCents } from '/assets/ui/invoicing.js';
16
16
  import {
17
- FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
17
+ caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
18
18
  } from '/assets/ui/job-board.js';
19
19
  import {
20
20
  DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, filterCount, filterJobs, groupJobs, GROUPS, laneOf, normaliseJobsView, NOT_SET, sortJobs, SORTS,
@@ -74,7 +74,7 @@ export async function renderJobs(main, ui) {
74
74
  const saved = ui.me?.views?.jobs;
75
75
  const state = {
76
76
  view: normaliseJobsView(saved ?? { ...DEFAULT_VIEW, layout: readLayout() }),
77
- q: asked.trim().toLowerCase(), jobs: [], statuses: [], accountingName: null, canDraft: false,
77
+ q: asked.trim().toLowerCase(), jobs: [], statuses: [], canDraft: false,
78
78
  // Custom fields (docs/CUSTOM_FIELDS.md): the lab's fields, the work
79
79
  // systems' stage fields, the people a person field can name, and each
80
80
  // job's values by job and field.
@@ -99,7 +99,7 @@ export async function renderJobs(main, ui) {
99
99
  drawToggle();
100
100
  search.addEventListener('input', () => { state.q = search.value.trim(); draw(); });
101
101
 
102
- const problemsOf = (job) => fixes(job.obligation, { accountingName: state.accountingName });
102
+ const problemsOf = (job) => fixes(job.obligation);
103
103
  const valueKey = (job, fieldId) => `${job.sourceSystem}\u0000${job.sourceJobRef}\u0000${fieldId}`;
104
104
  const valueOf = (job, fieldId) => state.values.get(valueKey(job, fieldId)) ?? null;
105
105
  const groupable = () => state.fields.filter((f) => f.groupable);
@@ -232,7 +232,6 @@ export async function renderJobs(main, ui) {
232
232
  ]);
233
233
  state.jobs = body.jobs;
234
234
  state.statuses = body.statuses;
235
- state.accountingName = body.accountingName;
236
235
  state.canDraft = context.canIssue;
237
236
  state.fields = fields?.fields ?? [];
238
237
  state.systemFields = fields?.systemFields ?? [];
@@ -495,7 +494,7 @@ export async function renderJobs(main, ui) {
495
494
  try {
496
495
  const { move } = await flow('/api/work/moves', { method: 'POST', body: JSON.stringify({ ...ref, toStatus: status }) });
497
496
  job.pendingMove = { to: move.to, from: move.from, requestedAt: move.requestedAt, expiresAt: move.expiresAt, openUrl: move.openUrl, openExact: move.openExact };
498
- ui.toast?.(`Waiting for ${system}. Set case ${move.caseRef} to ${move.to} there.`, 'success');
497
+ ui.toast?.(`Waiting for ${system}. Set ${caseNumber(move) ? `case ${caseNumber(move)}` : 'the case'} to ${move.to} there.`, 'success');
499
498
  } catch (error) {
500
499
  job.pendingMove = before;
501
500
  ui.toast?.(error.message, 'danger');
@@ -512,13 +511,14 @@ export async function renderJobs(main, ui) {
512
511
  // system's web app. An exact link opens the order itself; otherwise it
513
512
  // opens the lab's home page there, and the person finds the case.
514
513
  function openStep(job, system) {
515
- const caseRef = job.caseRef ?? job.sourceJobRef;
514
+ const caseRef = caseNumber(job);
515
+ const named = caseRef ? `case ${caseRef}` : `the case for ${clientOf(job)}`;
516
516
  const url = job.link?.url ?? null;
517
- if (!url) return `Open case ${caseRef} in ${system}.`;
517
+ if (!url) return `Open ${named} in ${system}.`;
518
518
  const out = (text) => h('a', { class: 'fx-task__link', href: url, target: '_blank', rel: 'noopener' }, text);
519
519
  return job.link.exact
520
- ? h('span', {}, out(`Open case ${caseRef} in ${system} ↗`))
521
- : h('span', {}, out(`Open ${system} ↗`), ` and find case ${caseRef}.`);
520
+ ? h('span', {}, out(caseRef ? `Open case ${caseRef} in ${system} ↗` : `Open the case in ${system} ↗`))
521
+ : h('span', {}, out(`Open ${system} ↗`), ` and find ${named}.`);
522
522
  }
523
523
 
524
524
  // The job panel's custom fields: one control each, saved when it changes.
@@ -681,7 +681,20 @@ export async function renderJobs(main, ui) {
681
681
  : stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
682
682
  const fixBox = found.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
683
683
  h('h3', {}, 'Fix'),
684
- found.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, `${f.text} ${f.todo}`)))) : null;
684
+ found.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, `${f.text} ${f.todo}`), fixLink(f.go)))) : null;
685
+ // Each fix's link: Flow's own invoice, the job in the work system, or
686
+ // the Flow page where the fix is made (job-board.js GO).
687
+ function fixLink(go) {
688
+ const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, onclick: (event) => { close(); ui.go(event); } }, text);
689
+ if (go === 'invoice' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
690
+ if (go === 'work' && job.link?.url) {
691
+ return h('a', { class: 'fx-task__link', href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`);
692
+ }
693
+ if (go === 'price-list') return inFlow('/price-list', 'Open price list');
694
+ if (go === 'invoicing-settings') return inFlow('/invoicing-settings', 'Open invoice rules');
695
+ if (go === 'integrations' && ui.me?.person?.role === 'owner') return inFlow('/integrations', 'Open integrations');
696
+ return null;
697
+ }
685
698
  const lines = job.lines ?? [];
686
699
  const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
687
700
  element.append(h('div', { class: 'fx-task__grid' },
@@ -697,13 +710,14 @@ export async function renderJobs(main, ui) {
697
710
  field(job.dueOn ? 'Due' : 'Ordered', job.dueOn ? fullDate(job.dueOn) : job.orderedOn ? fullDate(job.orderedOn) : null, late ? { class: 'fx-task__late' } : {}),
698
711
  job.dueOn ? field('Ordered', job.orderedOn ? fullDate(job.orderedOn) : null) : null,
699
712
  field('Total', job.totalCents == null ? null : formatCents(job.totalCents)),
700
- field('Case number', job.caseRef ?? job.sourceJobRef, { class: 'fx-mono' }),
713
+ field('Case number', caseNumber(job), { class: 'fx-mono' }),
701
714
  job.link?.url ? field(system, link({ href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`)) : null,
702
715
  field(`${system} status`, job.status),
703
716
  field(`${system} client`, job.workClientName && job.workClientName !== job.clientName ? job.workClientName : null),
704
717
  field('Department', job.department),
705
- field('Invoice', invoiceText),
706
- o?.accountingUrl ? field(state.accountingName ?? 'Accounting', link({ href: o.accountingUrl, target: '_blank', rel: 'noopener' }, `Open in ${state.accountingName ?? 'the accounting system'}`)) : null),
718
+ // The job links to Flow's invoice only. The invoice page shows its
719
+ // link to an accounting system (its Linked card).
720
+ field('Invoice', invoiceText)),
707
721
  fixBox,
708
722
  state.fields.length ? h('section', { class: 'fx-task__custom', 'aria-label': 'Custom fields' },
709
723
  h('h3', {}, 'Fields'),
@@ -21,7 +21,7 @@ const themeDir = join(here, '..', 'theme');
21
21
  const logoDir = join(here, 'logos');
22
22
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
23
23
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
24
- const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'flow-mark.svg']);
24
+ const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'flow-mark.svg']);
25
25
  const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
26
26
  const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
27
27
  const MAX_AGE = '1h';