@arcforgelabs/flow 2026.11.2 → 2026.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/ui/app.js CHANGED
@@ -18,6 +18,7 @@ import { renderPriceList } from '/assets/ui/price-list.js';
18
18
  import { renderPriceReconcile } from '/assets/ui/price-reconcile.js';
19
19
  import { renderCustomFields } from '/assets/ui/custom-fields.js';
20
20
  import { renderJobs } from '/assets/ui/jobs.js';
21
+ import { renderHome } from '/assets/ui/home.js';
21
22
  import { renderReconcile } from '/assets/ui/reconcile.js';
22
23
  import { renderXeroImport } from '/assets/ui/xero-import.js';
23
24
  import { syncStatus } from '/assets/ui/sync-status.js';
@@ -153,6 +154,7 @@ let me = null;
153
154
  // menu. `sidebar: false`: opened by a link or Search only. Jobs and Invoices
154
155
  // cannot be unpinned (nav-pins.js).
155
156
  const VIEWS = {
157
+ home: { label: 'Home', icon: 'home', render: (main) => renderHome(main, ui()) },
156
158
  jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
157
159
  captures: { label: 'Capture', icon: 'camera', render: (main) => renderCaptures(main, ui()) },
158
160
  invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
@@ -174,6 +176,7 @@ const VIEWS = {
174
176
 
175
177
  // Line icons for the sidebar, drawn on a 24-unit grid in the text colour.
176
178
  const ICON_PATHS = {
179
+ home: '<path d="M3 11l9-7 9 7"/><path d="M5 10v10h14V10"/><path d="M10 20v-6h4v6"/>',
177
180
  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"/>',
178
181
  invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
179
182
  contacts: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M6 16c.4-1.6 1.5-2.4 3-2.4s2.6.8 3 2.4M14 9h5M14 13h4"/>',
@@ -293,9 +296,9 @@ function reportMenuItem(openReport) {
293
296
  return accountMenuItem('Report a problem', 'flag', () => { openReport().catch((error) => toast(error.message, 'danger')); });
294
297
  }
295
298
 
296
- // The page a person lands on after sign-in, and for / or an unknown path.
297
- // Invoices opens on its Ready tab. A home page can take this over later.
298
- const START_VIEW = 'invoices';
299
+ // The page a person lands on after sign-in, and for / or an unknown path:
300
+ // Home, with the invoices ready to send and one Send.
301
+ const START_VIEW = 'home';
299
302
 
300
303
  // The view and, for one record, its id: /invoices/<id> is ['invoices', '<id>'].
301
304
  function currentView() {
@@ -369,7 +372,7 @@ function sidebarViews() {
369
372
  return Object.entries(VIEWS).filter(([, view]) => view.sidebar !== false && view.place !== 'account' && (!view.ownerOnly || me?.person.role === 'owner'));
370
373
  }
371
374
 
372
- // Null until the shell reads the account. Jobs and Invoices are always in it.
375
+ // Null until the shell reads the account. Home, Jobs and Invoices are always in it.
373
376
  let pinned = null;
374
377
  let pinEpoch = 0;
375
378
  let pinWrite = Promise.resolve();
@@ -359,11 +359,78 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
359
359
  background: #fff; box-shadow: inset 0 0 0 1px var(--fx-line);
360
360
  }
361
361
  .fx-logo--icon { background: var(--t-accent-bg); color: var(--t-accent); box-shadow: none; }
362
+ /* Home: the invoices ready to send and one Send, then the way an invoice
363
+ goes after Send, drawn as one line of steps (a column on a phone). */
364
+ .fx-home { display: grid; gap: 20px; max-width: 880px; }
365
+ .fx-home__send {
366
+ position: relative; overflow: hidden;
367
+ display: grid; justify-items: start; gap: 6px; padding: 32px;
368
+ border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg);
369
+ background:
370
+ radial-gradient(120% 140% at 100% 0%, color-mix(in srgb, var(--t-primary) 10%, transparent), transparent 60%),
371
+ var(--t-paper);
372
+ }
373
+ .fx-home__send[data-done] {
374
+ grid-template-columns: auto 1fr; align-items: center; column-gap: 16px;
375
+ background:
376
+ radial-gradient(120% 140% at 100% 0%, color-mix(in srgb, var(--t-success) 12%, transparent), transparent 60%),
377
+ var(--t-paper);
378
+ }
379
+ .fx-home__send[data-done] > :not(.fx-home__done) { grid-column: 2; }
380
+ .fx-home__done {
381
+ grid-row: span 2; display: grid; place-items: center; width: 52px; height: 52px; border-radius: 50%;
382
+ background: var(--t-success-bg); color: var(--t-success);
383
+ }
384
+ .fx-home__done .fx-lucide { width: 26px; height: 26px; }
385
+ .fx-home__count {
386
+ display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px;
387
+ color: var(--t-strong); font: 700 72px/1 var(--t-body); font-variant-numeric: tabular-nums; letter-spacing: -0.02em;
388
+ }
389
+ .fx-home__count span { font: 600 20px/1.2 var(--t-body); letter-spacing: 0; color: var(--t-text); }
390
+ .fx-home__send[data-done] .fx-home__count span { font-size: 22px; color: var(--t-strong); }
391
+ .fx-home__sum { color: var(--t-dim); font-size: 16px; font-variant-numeric: tabular-nums; }
392
+ .fx-home__btn { min-height: 56px; min-width: 200px; margin-top: 14px; padding: 0 32px; font-size: 17px; border-radius: 999px; }
393
+ .fx-home__btn .fx-lucide { width: 20px; height: 20px; }
394
+ .fx-home__note { color: var(--t-dim); font-size: 13.5px; }
395
+
396
+ /* Needs you: at most three rows beside Send; each opens its page. */
397
+ .fx-home__needs { display: grid; align-content: start; gap: 10px; }
398
+ .fx-home__needs h2 { margin: 0; }
399
+ .fx-home__needs ul { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
400
+ .fx-home__needs a {
401
+ display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px;
402
+ min-height: 64px; padding: 10px 16px; border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg);
403
+ background: var(--t-paper); color: var(--t-text); text-decoration: none;
404
+ transition: border-color 140ms var(--fx-ease), transform 140ms var(--fx-ease);
405
+ }
406
+ .fx-home__needs a:hover, .fx-home__needs a:focus-visible { border-color: var(--t-control); transform: translateY(-1px); }
407
+ .fx-home__dot { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; background: var(--fx-hover); color: var(--t-dim); }
408
+ .fx-home__dot .fx-lucide { width: 18px; height: 18px; }
409
+ .fx-home__needs [data-tone="danger"] .fx-home__dot { background: var(--t-danger-bg); color: var(--t-danger); }
410
+ .fx-home__needs [data-tone="warn"] .fx-home__dot { background: var(--t-warn-bg); color: var(--t-warn); }
411
+ .fx-home__needs [data-tone="info"] .fx-home__dot { background: var(--t-accent-bg); color: var(--t-accent); }
412
+ .fx-home__needs [data-tone="clear"] .fx-home__dot { background: var(--t-success-bg); color: var(--t-success); }
413
+ .fx-home__what { display: grid; font-weight: 600; color: var(--t-strong); }
414
+ .fx-home__what small { color: var(--t-dim); font-weight: 400; font-size: 13px; font-variant-numeric: tabular-nums; }
415
+ .fx-home__needs [data-tone="clear"] .fx-home__what { color: var(--t-dim); font-weight: 500; }
416
+ .fx-home__n { color: var(--t-strong); font: 700 22px/1 var(--t-body); font-variant-numeric: tabular-nums; }
417
+ .fx-home__needs [data-tone="clear"] .fx-home__n { color: var(--t-dim); font-weight: 500; }
418
+
419
+ @media (min-width: 1024px) {
420
+ .fx-home { grid-template-columns: minmax(0, 3fr) minmax(260px, 2fr); align-items: stretch; max-width: 1040px; }
421
+ .fx-home__send { align-content: center; }
422
+ }
423
+ @media (max-width: 639px) {
424
+ .fx-home__send { padding: 24px 20px; }
425
+ .fx-home__count { font-size: 60px; }
426
+ .fx-home__btn { width: 100%; }
427
+ }
428
+
362
429
  .fx-tiles { display: grid; gap: 12px; }
363
430
  .fx-tiles + .fx-tiles { margin-top: 16px; }
364
431
  .fx-tiles__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
365
432
  .fx-tiles__head h2 { color: var(--t-strong); font: 600 15px/1.3 var(--t-body); }
366
- .fx-tiles__more { display: inline-flex; align-items: center; gap: 4px; color: var(--t-dim); font-size: 13.5px; text-decoration: none; }
433
+ .fx-tiles__more { display: inline-flex; align-items: center; gap: 4px; min-height: 44px; color: var(--t-dim); font-size: 13.5px; text-decoration: none; }
367
434
  .fx-tiles__more:hover, .fx-tiles__more:focus-visible { color: var(--t-strong); }
368
435
  .fx-tiles__more .fx-lucide { width: 15px; height: 15px; }
369
436
  .fx-tiles__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 12px; }
@@ -0,0 +1,120 @@
1
+ // Home: the page a person lands on. One job first: send the invoices that
2
+ // are ready. The big number is the Ready tab's count, and Send sends them
3
+ // all with the same step as the Invoices page (POST /invoices/send-ready).
4
+ // Beside it, at most three things that need a person most days: invoices
5
+ // that need a look, jobs and invoices to match (Reconcile), and form photos
6
+ // to check (Capture). Each opens its page. No calls of its own to other
7
+ // systems: every number is its page's own.
8
+ import { formatCents, localDate } from '/assets/ui/invoicing.js';
9
+ import { sendModeNote } from '/assets/ui/invoice-notes.js';
10
+ import { lucide } from '/assets/ui/custom-fields.js';
11
+
12
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
13
+
14
+ function greeting(person, now = new Date()) {
15
+ const hour = now.getHours();
16
+ const part = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
17
+ const first = String(person?.name ?? '').trim().split(/\s+/)[0];
18
+ return first ? `${part}, ${first}` : part;
19
+ }
20
+
21
+ export async function renderHome(main, ui) {
22
+ const { h, flow, toast, notice, pageHeader, dialog, go, me } = ui;
23
+ const host = h('div', { class: 'fx-home' });
24
+ main.append(pageHeader(greeting(me?.person)), host);
25
+ const tabQuery = (tab) => `/api/invoicing/invoices?${new URLSearchParams({ status: tab, limit: '1' })}`;
26
+
27
+ async function load() {
28
+ let context;
29
+ let body;
30
+ let pending;
31
+ let toMatch;
32
+ let forms;
33
+ try {
34
+ [context, body, pending, toMatch, forms] = await Promise.all([
35
+ flow('/api/invoicing/context'),
36
+ flow(tabQuery('ready')),
37
+ flow(tabQuery('pending')),
38
+ // The two below are extras: Home still shows without them.
39
+ flow('/api/invoicing/reconcile').catch(() => null),
40
+ flow('/api/captures').catch(() => null),
41
+ ]);
42
+ } catch (error) {
43
+ host.replaceChildren(notice(error.message, 'danger', 'Flow could not load your invoices.'));
44
+ return;
45
+ }
46
+ if (!host.isConnected) return;
47
+ draw(context, body, [
48
+ { href: '/invoices?tab=pending', icon: 'circle-alert', label: 'Need a look', tone: 'danger', count: pending.total, sum: pending.totalCents },
49
+ toMatch && { href: '/reconcile', icon: 'link', label: 'To match', tone: 'warn', count: (toMatch.jobs?.length ?? 0) + (toMatch.invoices?.length ?? 0) },
50
+ forms && { href: '/captures', icon: 'camera', label: 'Forms to check', tone: 'info', count: forms.captures.filter((c) => c.state === 'ready').length },
51
+ ].filter(Boolean));
52
+ }
53
+
54
+ function draw(context, body, items) {
55
+ const to = body.sendsTo;
56
+ const counts = body.counts ?? {};
57
+ const ready = counts.ready ?? 0;
58
+ const blocked = to && body.sendMode && (!body.sendMode.connected || !body.sendMode.writesAllowed);
59
+ const canSend = context.canIssue && !blocked;
60
+
61
+ const hero = ready
62
+ ? h('section', { class: 'fx-home__send', 'aria-labelledby': 'home-ready' },
63
+ h('p', { class: 'fx-home__count', id: 'home-ready' }, String(ready),
64
+ h('span', {}, ready === 1 ? 'invoice ready' : 'invoices ready')),
65
+ h('p', { class: 'fx-home__sum' }, formatCents(body.totalCents)),
66
+ h('button', {
67
+ class: 'fx-btn fx-btn--primary fx-home__btn', type: 'button', disabled: !canSend,
68
+ onclick: (event) => send(to, ready, body.sendMode, event.currentTarget),
69
+ }, lucide(h, 'send'), h('span', {}, 'Send')),
70
+ context.canIssue ? null : h('p', { class: 'fx-home__note' }, 'An owner or admin sends.'),
71
+ blocked ? h('p', { class: 'fx-home__note' }, sendModeNote(to, body.sendMode)) : null)
72
+ : h('section', { class: 'fx-home__send', 'data-done': '', 'aria-labelledby': 'home-ready' },
73
+ h('span', { class: 'fx-home__done', 'aria-hidden': 'true' }, lucide(h, 'check')),
74
+ h('p', { class: 'fx-home__count', id: 'home-ready' }, h('span', {}, 'All caught up')),
75
+ h('p', { class: 'fx-home__sum' }, 'Nothing waits to be sent.'));
76
+
77
+ const needs = h('section', { class: 'fx-home__needs', 'aria-labelledby': 'home-needs' },
78
+ h('h2', { class: 'fx-label', id: 'home-needs' }, 'Needs you'),
79
+ h('ul', {}, items.map((item) => h('li', {},
80
+ h('a', { href: item.href, onclick: open, 'data-tone': item.count ? item.tone : 'clear' },
81
+ h('span', { class: 'fx-home__dot', 'aria-hidden': 'true' }, lucide(h, item.count ? item.icon : 'check')),
82
+ h('span', { class: 'fx-home__what' }, item.label,
83
+ item.count && item.sum ? h('small', {}, formatCents(item.sum)) : null),
84
+ h('span', { class: 'fx-home__n' }, String(item.count)))))));
85
+
86
+ host.replaceChildren(hero, needs);
87
+ }
88
+
89
+ function open(event) {
90
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
91
+ event.preventDefault();
92
+ go(event.currentTarget.getAttribute('href'));
93
+ }
94
+
95
+ async function send(to, ready, sendMode, button) {
96
+ const ok = await dialog({
97
+ title: `Send ${plural(ready, 'invoice', 'invoices')}?`,
98
+ body: h('p', {}, to ? sendModeNote(to, sendMode, 'each one') : 'Each one gets the next number and is locked.'),
99
+ confirm: 'Send',
100
+ });
101
+ if (!ok) return;
102
+ button.disabled = true;
103
+ button.lastChild.textContent = 'Sending';
104
+ try {
105
+ const res = await flow('/api/invoicing/invoices/send-ready', { method: 'POST', body: JSON.stringify({ issueDate: localDate() }) });
106
+ const notes = [
107
+ to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`,
108
+ res.held ? `${res.held} need a look.` : '',
109
+ res.waiting ? `${res.waiting} could not be checked with the work system yet: press Send again.` : '',
110
+ res.remaining ? `${res.remaining} more are ready: press Send again.` : '',
111
+ ].filter(Boolean);
112
+ toast(notes.join(' '), res.held || res.waiting || res.remaining ? 'info' : 'success', { stay: Boolean(res.held || res.waiting) });
113
+ } catch (error) {
114
+ toast(error.message, 'danger', { stay: true });
115
+ }
116
+ await load();
117
+ }
118
+
119
+ await load();
120
+ }
@@ -111,7 +111,7 @@ function toCents(text) {
111
111
 
112
112
  const fromCents = (cents) => (cents == null ? '' : (cents / 100).toFixed(2));
113
113
 
114
- function localDate(date = new Date()) {
114
+ export function localDate(date = new Date()) {
115
115
  const pad = (n) => String(n).padStart(2, '0');
116
116
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
117
117
  }
@@ -146,6 +146,13 @@ function readFilters() {
146
146
 
147
147
  export async function renderInvoiceList(main, ui) {
148
148
  const { h, flow, listHead, listFrame, notice, go, toast } = ui;
149
+ // A link may name the tab (/invoices?tab=draft, from Home). It is kept as
150
+ // the open tab, and the address goes back to /invoices.
151
+ const linked = new URLSearchParams(globalThis.location?.search ?? '').get('tab');
152
+ if (linked) {
153
+ sessionStorage.setItem('flow_invoice_tab', linked);
154
+ history.replaceState(null, '', location.pathname);
155
+ }
149
156
  const saved = OLD_TAB[sessionStorage.getItem('flow_invoice_tab')] ?? sessionStorage.getItem('flow_invoice_tab');
150
157
  // Back from an invoice comes here with the tab, search and scroll it left.
151
158
  const back = readNav();
@@ -81,7 +81,7 @@ const sentence = (text) => capital(String(text).trim().replace(/\.?$/, '.'));
81
81
  // wrong, what to do, and where to go to do it (`go`, see GO). A job's fix
82
82
  // speaks of Flow's own invoice only. How that invoice stands in an
83
83
  // accounting system is the invoice's business, on the invoice page.
84
- function reasonFix(reason) {
84
+ function reasonFix(reason, o = {}) {
85
85
  const fix = (label, todo, go = null) => ({ label, text: sentence(reason), todo, go });
86
86
  if (reason === 'the accounting system was not checked') {
87
87
  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' };
@@ -111,7 +111,12 @@ function reasonFix(reason) {
111
111
  }
112
112
  if (/^invoice .* was (voided|deleted)$/.test(reason)) {
113
113
  const voided = /voided$/.test(reason);
114
- 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' };
114
+ const label = `Invoice ${voided ? 'voided' : 'deleted'}`;
115
+ const text = `The invoice for this job was ${voided ? 'voided' : 'deleted'}.`;
116
+ // No Flow invoice: the voided one is in the accounting system only, so
117
+ // the job's own buttons make its new invoice (jobs.js).
118
+ if (!o.invoiceId) return { label, text, todo: null, go: 'choose' };
119
+ return { label, text, todo: 'Make a new invoice, or close the job.', go: 'invoice' };
115
120
  }
116
121
  if (/^the accounting system reports /.test(reason)) {
117
122
  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' };
@@ -160,7 +165,7 @@ export const GO = ['invoice', 'work', 'price-list', 'rule', 'integrations', 'rev
160
165
  export function fixes(o) {
161
166
  if (invoiceStage(o) !== 'problem') return [];
162
167
  const found = [];
163
- if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
168
+ if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r, o)));
164
169
  found.push(...(o.waitingRules ?? []).map(ruleFix));
165
170
  if (o.invoiceStatus === 'void') {
166
171
  found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
@@ -1,9 +1,10 @@
1
1
  // Which sidebar pages a person has pinned. Stored with the account in
2
2
  // people.views.nav (PUT /api/me/views/nav). No table of its own.
3
- // Jobs and Invoices stay pinned: a request that leaves them out is saved
3
+ // Home, Jobs and Invoices stay pinned: a request that leaves them out is saved
4
4
  // with them put back. A page Flow does not know is dropped.
5
5
 
6
6
  export const NAV_PAGES = Object.freeze([
7
+ 'home',
7
8
  'jobs',
8
9
  'captures',
9
10
  'invoices',
@@ -16,12 +17,13 @@ export const NAV_PAGES = Object.freeze([
16
17
  'integrations',
17
18
  ]);
18
19
 
19
- export const NAV_LOCKED = Object.freeze(['jobs', 'invoices']);
20
+ export const NAV_LOCKED = Object.freeze(['home', 'jobs', 'invoices']);
20
21
 
21
- // Today's sidebar, before a person has chosen: daily work (Jobs, Capture,
22
+ // Today's sidebar, before a person has chosen: Home, daily work (Jobs, Capture,
22
23
  // Invoices, Contacts, Reconcile), Price list and Rules. Automations, Custom fields
23
24
  // and Integrations start in More.
24
25
  export const NAV_DEFAULT = Object.freeze([
26
+ 'home',
25
27
  'jobs',
26
28
  'captures',
27
29
  'invoices',
@@ -470,13 +470,15 @@ async function renderGap(main, ui, id) {
470
470
  };
471
471
  });
472
472
  results.replaceChildren(...[
473
- job.open ? null : h('p', { class: 'fx-notice' }, 'This job has an invoice already.'),
473
+ job.open || job.unclear ? null : h('p', { class: 'fx-notice' }, 'This job has an invoice already.'),
474
474
  candidates.length ? compareView({ ours: jobFacts(job), oursTitle: 'This job', oursSub: system, candidates })
475
475
  : h('p', { class: 'fx-empty' }, q.trim() ? 'No Flow invoice matches the search.' : 'Flow found no invoice that may be for this job. Find one, or make it.'),
476
476
  ].flat().filter(Boolean));
477
477
  actions.replaceChildren(...[
478
478
  skip(),
479
- canAct && job.open ? h('button', {
479
+ // An unclear job (a voided or deleted Xero link) is not open to a link,
480
+ // but Make invoice settles it.
481
+ canAct && (job.open || job.unclear) ? h('button', {
480
482
  class: `fx-btn${candidates.some((c) => c.best) ? '' : ' fx-btn--primary'}`, type: 'button',
481
483
  onclick: () => act(async () => {
482
484
  if (job.unclear) await flow(`/api/invoicing/jobs/${job.id}/no-invoice`, { method: 'POST', body: '{}' });
@@ -22,7 +22,7 @@ const themeDir = join(here, '..', 'theme');
22
22
  const logoDir = join(here, 'logos');
23
23
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
24
24
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
25
- export const UI_FILES = new Set(['sync-status.js', 'flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
25
+ export const UI_FILES = new Set(['sync-status.js', 'flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'home.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
26
26
  const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
27
27
  const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
28
28
  const MAX_AGE = '1h';
@@ -0,0 +1,237 @@
1
+ ---
2
+ name: flow
3
+ description: Run the lab's Flow app and read Seazona through it - invoice tabs, send ready invoices, void or delete drafts, invoice rules, jobs, reconcile Seazona jobs with Flow and Xero, compare prices across Flow, Xero and Seazona, link price codes, review items, the Seazona history import, and Seazona orders, invoices and clients.
4
+ metadata: { "openclaw": { "requires": { "bins": ["curl", "openssl", "sha256sum"] } } }
5
+ ---
6
+
7
+ # Flow
8
+
9
+ Flow is the lab's invoicing app. It takes jobs from Seazona (the lab's case
10
+ system) and sends invoices to Xero. Use `{baseDir}/flow.sh` for every call.
11
+ It holds the Flow key; never print the key file or put the key in a message.
12
+
13
+ Flow address: saved by `setup` next to the key (or `FLOW_URL`). There is no
14
+ default: the key goes only to this lab's own Flow.
15
+ Key file: `FLOW_AGENT_KEY_FILE` (default `~/.openclaw/credentials/flow-agent-key`).
16
+
17
+ ## First time only
18
+
19
+ Run `{baseDir}/flow.sh setup https://<this lab's Flow>`. It saves the address,
20
+ makes the key once and prints only `FLOW_AGENT_KEY_SHA256=<64 hex>`. That digest is safe to share: give it to the
21
+ person who sets up Flow. Flow stores the digest, never the key. Run `setup`
22
+ again any time to see the same digest. To change the key, delete the key file,
23
+ run `setup`, and give the new digest to Flow.
24
+
25
+ Then check: `{baseDir}/flow.sh get me` must answer `HTTP 200`.
26
+
27
+ ## Keep the skill current
28
+
29
+ Run `{baseDir}/flow.sh update` after Flow gets a new release, and when a call
30
+ answers 404 for a route this skill lists. It gets this skill from the Flow it
31
+ talks to, so the skill always matches the release that Flow runs. It changes
32
+ nothing if the answer is not a good skill. Install the skill from GitHub only
33
+ the first time.
34
+
35
+ ## Calls
36
+
37
+ Every answer ends with a line `HTTP <code>`. 401 or 403: the key is not set up
38
+ in Flow, or the Flow person is turned off. Stop and tell the person.
39
+
40
+ Read first. Before a change, read the record, say what you will change, and
41
+ for sends, voids and deletes get the person's yes in the chat.
42
+
43
+ | Do this | Command |
44
+ | --- | --- |
45
+ | Tabs and counts | `flow.sh get 'invoicing/invoices?status=ready&limit=50'` (status: pending, ready, draft, approved, sent, non_chargeable, void) |
46
+ | Search invoices | `flow.sh get 'invoicing/invoices?q=<text>'` |
47
+ | One invoice | `flow.sh get invoicing/invoices/<id>` |
48
+ | Mark ready | `flow.sh post invoicing/invoices/mark-ready '{"invoiceIds":["<id>"]}'` |
49
+ | Send all ready to Xero | `flow.sh post invoicing/invoices/send-ready '{}'` (or `'{"invoiceIds":["<id>"]}'`) |
50
+ | Void an invoice | `flow.sh post invoicing/invoices/<id>/void` |
51
+ | Delete a draft | `flow.sh delete invoicing/invoices/<id>` |
52
+ | Jobs | `flow.sh get 'invoicing/jobs?limit=50'`, one job `invoicing/jobs/<id>/activity` |
53
+ | Invoice rules | `flow.sh get invoicing/rules`; what a rule can use: `flow.sh get invoicing/rule-fields` |
54
+ | Add a rule | `flow.sh post invoicing/rules '{"name":"...","conditions":[{"field":"job_total","test":"at_most","value":0}],"action":{"kind":"wait"}}'` |
55
+ | Change or turn off a rule | `flow.sh put invoicing/rules/<id> '{"enabled":false}'` |
56
+ | Delete a rule | `flow.sh delete invoicing/rules/<id>` |
57
+ | Automations | `flow.sh get invoicing/automations` |
58
+ | Flow settings | `flow.sh get invoicing/settings` |
59
+
60
+ Rule actions: `wait` (hold the job) or `skip` (never invoice it). Take field,
61
+ test and value only from `rule-fields`.
62
+
63
+ Voids: the Void tab holds invoices voided in Flow **or voided or deleted in
64
+ Xero**. Before a clean-up, list the tab, group the rows (Flow void, Xero void,
65
+ deleted draft), show the person the groups, and act only on the group they
66
+ confirm.
67
+
68
+ Sending: Flow sends each invoice to Xero as a draft unless the "Approve and
69
+ email" setting is on (`invoicing/settings`). Say which one applies before a send.
70
+
71
+ ## Prices and price codes
72
+
73
+ Flow's price list is the lab's own list of codes and prices
74
+ (`docs/PRICE_LIST.md`). Flow keeps a copy of the Xero items and the Seazona
75
+ products and links each one to a Flow code. `price-list` reads only Flow's
76
+ copies, so it costs no Xero or Seazona calls.
77
+
78
+ | Do this | Command |
79
+ | --- | --- |
80
+ | Compare all prices | `flow.sh get invoicing/price-list` |
81
+ | Flow codes only | `flow.sh get 'invoicing/price-items?q=<text>'` |
82
+ | Change a Flow price | `flow.sh put invoicing/price-items/<id> '{"unitAmountCents":13500}'` (also `name`, `taxCode` GST or GST_FREE) |
83
+ | Add a Flow code | `flow.sh post invoicing/price-items '{"code":"...","name":"...","unitAmountCents":13500,"taxCode":"GST"}'` |
84
+ | Link records to codes | `flow.sh post invoicing/price-links '{"links":[{"priceItemId":"<id>","system":"xero","externalId":"<id>"}]}'` |
85
+ | Remove a link | `flow.sh delete invoicing/price-links/<link id>` |
86
+ | Accept a difference | `flow.sh post invoicing/price-items/<id>/resolve` |
87
+ | Lines that wait for a code | `flow.sh get invoicing/line-labels` (rows with `unmapped` above 0) |
88
+ | Give a line its code | `flow.sh post invoicing/line-mappings '{"kind":"saved","label":"<label>","unitAmountCents":<label price>,"itemCode":"<Flow code>"}'` |
89
+ | Saved code choices | `flow.sh get invoicing/line-mappings`; change `put invoicing/line-mappings/<id>`, remove `delete invoicing/line-mappings/<id>` |
90
+
91
+ How to read `price-list`:
92
+
93
+ - `systems`: each connected list (`xero`, `seazona`), its record count and
94
+ `syncedAt`. An old `syncedAt` means Flow's copy can be behind.
95
+ - `items`: each Flow code with its `links` (the linked Xero item or Seazona
96
+ product, as `record`) and `findings`. A finding `kind` is `price` (Flow and
97
+ the other system differ: `flow` and `other` in cents), `tax`, `missing` (the
98
+ linked record is gone) or `not_linked` (no Xero item for this code).
99
+ - `unlinked`: Xero items and Seazona products that no Flow code has yet.
100
+
101
+ Reconcile steps:
102
+
103
+ 1. Read `price-list` and `line-labels`. Show the person one table: each code
104
+ with the Flow, Xero and Seazona price, what differs, and each line label
105
+ that waits.
106
+ 2. For each difference, ask which price is right. Do not guess.
107
+ 3. When the person says yes, fix it in the right place:
108
+ - Flow price or code: the Flow commands above.
109
+ - Xero item: your own `xero` command (Flow never changes a Xero item).
110
+ Then refresh Flow's copy of Xero items and contacts:
111
+ `flow.sh post xero/sync '{"organisationId":"<org>"}'`.
112
+ - Seazona product: read only. Tell the person to change it in Seazona.
113
+ - A record that is the same thing as a code but has no link: link it.
114
+ - A difference that is correct (for example a Seazona product at $0):
115
+ accept it with `resolve`.
116
+ 4. Waiting lines: match each label to a code by name and price, show the
117
+ person the pairs, and save each pair they agree with as a line mapping.
118
+ The `label` and `unitAmountCents` come from that `line-labels` row. Jobs
119
+ with that line move on at the next Flow sync (a few minutes).
120
+ 5. Read `price-list` and `line-labels` again and show what is left.
121
+
122
+ Every change on this list (price, code, link, resolve, line mapping) needs
123
+ the person's yes in the chat first, like a send.
124
+
125
+ ## Seazona (read only)
126
+
127
+ Flow reads Seazona for you with its own Seazona connection, so you need no
128
+ Seazona password. Calls share Seazona's limit of 30 a minute with Flow's
129
+ sync: make few calls, and never loop over many records.
130
+
131
+ | Do this | Command |
132
+ | --- | --- |
133
+ | Orders on one day | `flow.sh get 'seazona/api/orders/?ordered=2026-11-11T00:00:00&page=1&pageSize=50'` |
134
+ | One order | `flow.sh get seazona/api/orders/<order id>` |
135
+ | Invoices changed since | `flow.sh get 'seazona/api/invoices/?lastModified=2026-11-01T00:00:00'` |
136
+ | Clients changed since | `flow.sh get 'seazona/api/clients/?lastModified=2026-11-01T00:00:00'` |
137
+ | Products, users | `flow.sh get seazona/api/products/`, `flow.sh get seazona/api/users/` |
138
+
139
+ Answers are `{ "ok": true, "data": ... }`, as Seazona gives them. If the owner
140
+ turned on "Hide patient details", patient, note, file and clinical fields are
141
+ left out. Do not copy patient names outside this chat. 429: wait a
142
+ minute and try once more.
143
+
144
+ ## Reconcile: Seazona jobs, Flow invoices, Xero
145
+
146
+ When the person says "reconcile", "tidy up" or "why is this job not
147
+ invoiced", work in this order. Read first, show the person what you found in
148
+ short groups, then act on the group they confirm.
149
+
150
+ 1. **Jobs against Flow invoices.** `flow.sh get invoicing/reconcile` lists
151
+ jobs with no invoice and invoices with no job. For one row:
152
+ `flow.sh get invoicing/jobs/<job id>/reconcile` or
153
+ `flow.sh get invoicing/invoices/<id>/reconcile` (each lists the likely other
154
+ side; `best: true` is the top guess).
155
+ - Link a job to its invoice: `flow.sh post invoicing/jobs/<job id>/link '{"invoiceId":"<id>"}'`
156
+ - Job needs no invoice: `flow.sh post invoicing/jobs/<job id>/no-invoice`
157
+ - Make the invoice now: `flow.sh post invoicing/jobs/<job id>/draft`
158
+ - Read the job again from Seazona: `flow.sh post invoicing/jobs/<job id>/recheck`
159
+ - **Stuck on a void invoice** (the job shows "Invoice voided", or its Flow
160
+ invoice is void, and nothing drafts): `flow.sh post invoicing/jobs/<job id>/free '{"reason":"<why, in the person's words>"}'`.
161
+ The job lets go of the void invoice and goes on hold, so it does not
162
+ draft by itself. It refuses a live Flow invoice: void that first, only
163
+ after the person says yes to that invoice
164
+ (`flow.sh post invoicing/invoices/<id>/void`).
165
+ - **Held or in review:** `flow.sh get invoicing/jobs/<job id>/activity`
166
+ shows why. Release it: `flow.sh post invoicing/jobs/<job id>/hold '{"state":"none","reason":"<why>"}'`;
167
+ hold it: `'{"state":"held","reason":"<why>"}'` (or `"review"`).
168
+ A `history_import` hold is refused here: use the history import decisions.
169
+ 2. **Flow against Xero.** First get the lab id:
170
+ `flow.sh get invoicing/context` (`organisations[0].id`, below `<org>`).
171
+ - Refresh Flow's copy of Xero: `flow.sh post xero/mirror '{"organisationId":"<org>"}'`
172
+ - Xero invoices against Flow's: `flow.sh get 'xero/import?organisationId=<org>&show=check'`
173
+ (show: `check`, `linked`, `invoice_fits`, `job_fits`, `none`, `all`)
174
+ - Match them all again: `flow.sh post xero/import/match '{"organisationId":"<org>"}'`
175
+ - Link one Xero invoice to a Flow invoice: `flow.sh post xero/import/<xero invoice id>/link '{"organisationId":"<org>","invoiceId":"<id>"}'`
176
+ - Make the Flow invoice for one Xero invoice: `flow.sh post xero/import/<xero invoice id>/make '{"organisationId":"<org>","jobId":"<job id or null>"}'`
177
+ - Xero's state of some Flow invoices: `flow.sh post xero/invoices/status '{"organisationId":"<org>","invoiceIds":["<id>"]}'`
178
+ - Re-check one invoice after a fix in Xero: `flow.sh post xero/invoices/<id>/recheck '{"organisationId":"<org>"}'`
179
+ - Contacts: `flow.sh get 'xero/contacts/match?organisationId=<org>&contactId=<Flow contact id>'`; link to a Xero contact of another name: `flow.sh post xero/contacts/link '{"organisationId":"<org>","contactId":"<id>","xeroContactId":"<id>"}'`
180
+ 3. **Fix the cause with a rule.** If many jobs fail the same way, read
181
+ `invoicing/rule-fields`, propose one rule (`wait` or `skip`), and add it after
182
+ the person says yes. Line names that map to the wrong product:
183
+ `flow.sh get invoicing/line-mappings`, then change one with `put
184
+ invoicing/line-mappings/<id>`. Prices: `flow.sh get invoicing/price-list`.
185
+ 4. **Check Seazona itself** for one job with the Seazona read above (one order).
186
+
187
+ Links, "no invoice", rules and mappings are Flow records only: say what you
188
+ will change and do it after the person's yes. Mirror, match and recheck only
189
+ read Xero. Sending to Xero still goes through `invoicing/invoices/send-ready`.
190
+
191
+ ## Review queue, health and history import
192
+
193
+ Use these to find gaps in past billing: jobs Flow never saw, jobs held from
194
+ the history import, and days where Seazona, Flow and Xero disagree.
195
+
196
+ | Do this | Command |
197
+ | --- | --- |
198
+ | Flow health (build, Seazona sync, daily reconcile, history counts) | `flow.sh health` |
199
+ | Open review items | `flow.sh get 'invoicing/review?limit=100'` (also `kind=`, `state=resolved`) |
200
+ | History import state, backfill and batches | `flow.sh get seazona/history` |
201
+ | One batch, or its rows that wait | `flow.sh get 'seazona/history/batches/<id>?outcome=needs_person'` |
202
+ | Held history jobs that need a person | `flow.sh get 'seazona/history/ledger?state=needs_person'` (no state: all) |
203
+ | Decide batch jobs | `flow.sh post seazona/history/batches/<id>/decisions '{"decisions":[{"jobKey":"<key>","decision":"import"}]}'` (`import`, `skip`, or `null` to undo) |
204
+ | Apply a batch (once only) | `flow.sh post seazona/history/batches/<id>/apply` |
205
+ | Read past Seazona days into Flow | `flow.sh post seazona/history/backfill '{"from":"2026-07-01"}'`; stop: `post seazona/history/backfill/stop` |
206
+ | Let one held history job draft | `flow.sh post seazona/history/ledger/<job id>/release` |
207
+
208
+ How to read them:
209
+
210
+ - `health`: `integrations[].health.history` counts history jobs and open
211
+ batches; `reconcile` shows the last daily check, `daysDifferent` and
212
+ `openItems`; Seazona `missing` lists Seazona records Flow's copy lacks.
213
+ - Review items: `kind` says what differs (for example `reconcile.day`,
214
+ `job.changed`, `job.source_totals`); `evidence` holds the numbers. An item
215
+ closes by itself when its cause is fixed, so fix the cause (link, mapping,
216
+ rule, recheck) and read the list again.
217
+ - History jobs are held with reason `history_import`: they are past work,
218
+ most already billed. A matched one stays held. Release only a job that the
219
+ person agrees was never invoiced.
220
+
221
+ Decisions, apply, backfill and release change Flow records or spend Seazona
222
+ calls: say what you will do and act only after the person's yes. A backfill
223
+ uses the shared Seazona limit a few calls at a time; one at a time only.
224
+
225
+ ## Xero
226
+
227
+ Xero has its own `xero` command on this Gateway, for anything Flow does not
228
+ cover. Flow's send and the reconcile steps above use Flow's own Xero
229
+ connection, not yours.
230
+
231
+ ## Never
232
+
233
+ - Never print, paste or send the key file, a Xero token or a callback URL.
234
+ - Never send, void or delete without the person's yes in this chat.
235
+ - Never send a turned-off person's work again without asking: a send stopped as
236
+ `requester_disabled` means a person turned the assistant off.
237
+ - Never change Flow people, Integrations or owner settings: Flow refuses them.