@arcforgelabs/flow 2026.10.43 → 2026.10.44

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/README.md CHANGED
@@ -35,6 +35,7 @@ run a lab.
35
35
  | `docs/index.md` | The docs site home page |
36
36
  | `docs/releases/` | One release notes page per version (`<version>.md`), shown on the docs site |
37
37
  | `docs/DESIGN.md` | The design systems (theme families), how pages and Clerk's components take them, and the screens |
38
+ | `docs/DESIGN_SYSTEM.md` | The Flow Design System artifact: how `npm run design-system` builds it from the code, the card per shared component, publishing and review |
38
39
  | `docs/DATA_MODEL.md` | The PostgreSQL records, the rules the database holds, and how to change the schema |
39
40
  | `docs/DATA_SPINE.md` | The design of the tables and jobs every integration shares: mirror, identity map, job queue, outbox, review queue |
40
41
  | `docs/JOB_INVOICE_LINKS.md` | The pipeline: how each job links to its customer and to the accounting invoice that bills it |
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.43",
3
+ "version": "2026.10.44",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.43",
9
+ "version": "2026.10.44",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
@@ -33,7 +33,8 @@
33
33
  "flow-server": "server/flow-server.js"
34
34
  },
35
35
  "devDependencies": {
36
- "drizzle-kit": "0.31.11"
36
+ "drizzle-kit": "0.31.11",
37
+ "esbuild": "0.25.12"
37
38
  },
38
39
  "engines": {
39
40
  "node": ">=24"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.43",
3
+ "version": "2026.10.44",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,7 +36,8 @@
36
36
  "theme:contrast": "node scripts/theme-contrast.mjs",
37
37
  "db:generate": "drizzle-kit generate && node scripts/db-guard-migrations.js",
38
38
  "db:follow-production": "node scripts/replit-workspace-db.js sync",
39
- "db:follow-production:status": "node scripts/replit-workspace-db.js status"
39
+ "db:follow-production:status": "node scripts/replit-workspace-db.js status",
40
+ "design-system": "node scripts/design-system/build.mjs"
40
41
  },
41
42
  "dependencies": {
42
43
  "@clerk/backend": "3.25.0",
@@ -60,7 +61,8 @@
60
61
  "pg": "8.23.1"
61
62
  },
62
63
  "devDependencies": {
63
- "drizzle-kit": "0.31.11"
64
+ "drizzle-kit": "0.31.11",
65
+ "esbuild": "0.25.12"
64
66
  },
65
67
  "overrides": {
66
68
  "@esbuild-kit/core-utils": {
@@ -2,7 +2,8 @@ import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
2
2
  import { people, PERSON_ROLES } from '../db/schema.js';
3
3
  import { FlowError } from '../core/errors.js';
4
4
  import { isThemeFamily, normalizeAccent } from '../theme/themes.js';
5
- import { normaliseJobsView } from '../ui/job-view.js';
5
+ import { INVOICE_COLUMNS, normaliseJobsView, normaliseListView, RECONCILE_COLUMNS } from '../ui/job-view.js';
6
+ import { normaliseSavedViews, SAVED_VIEW_PAGES } from '../ui/list-views.js';
6
7
  import { isPlainEmail } from './settings.js';
7
8
 
8
9
  // The people who may sign in, and their roles. Only an owner changes this
@@ -57,10 +58,12 @@ export async function setPersonAppearance(db, { id, appearance }) {
57
58
  function viewCheck(page) {
58
59
  switch (page) {
59
60
  case 'jobs': return normaliseJobsView;
61
+ case 'invoices': return (view) => normaliseListView(view, INVOICE_COLUMNS);
62
+ case 'reconcile': return (view) => normaliseListView(view, RECONCILE_COLUMNS);
60
63
  default: return null;
61
64
  }
62
65
  }
63
- const VIEW_PAGE_NAMES = ['jobs'];
66
+ const VIEW_PAGE_NAMES = ['jobs', 'invoices', 'reconcile'];
64
67
 
65
68
  // A person's saved views as Flow sends them: only the known pages, each
66
69
  // checked, so a view saved by an older Flow still loads.
@@ -68,9 +71,31 @@ export function normaliseViews(input) {
68
71
  const value = input && typeof input === 'object' ? input : {};
69
72
  const out = {};
70
73
  for (const page of VIEW_PAGE_NAMES) if (value[page]) out[page] = viewCheck(page)(value[page]);
74
+ // The named views a person saved on each page, and their default
75
+ // (ui/list-views.js), kept under `saved`.
76
+ const saved = value.saved && typeof value.saved === 'object' ? value.saved : {};
77
+ const lists = {};
78
+ for (const page of SAVED_VIEW_PAGES) if (saved[page]) lists[page] = normaliseSavedViews(page, saved[page]);
79
+ if (Object.keys(lists).length) out.saved = lists;
71
80
  return out;
72
81
  }
73
82
 
83
+ // Each person keeps their own named views of a page and picks their
84
+ // default; saving one page's list leaves the other pages as they were.
85
+ export async function setPersonSavedViews(db, { id, page, saved }) {
86
+ if (!SAVED_VIEW_PAGES.includes(page)) throw new FlowError('not_found', 'no such page');
87
+ const value = normaliseSavedViews(page, saved);
88
+ const [row] = await db.update(people)
89
+ .set({
90
+ views: sql`coalesce(${people.views}, '{}'::jsonb) || jsonb_build_object('saved', coalesce(${people.views}->'saved', '{}'::jsonb) || ${JSON.stringify({ [page]: value })}::jsonb)`,
91
+ updatedAt: new Date(),
92
+ })
93
+ .where(eq(people.id, id))
94
+ .returning();
95
+ if (!row) throw new FlowError('not_found', 'person not found');
96
+ return value;
97
+ }
98
+
74
99
  // Each person keeps their own view of a page; saving one page leaves the
75
100
  // others as they were.
76
101
  export async function setPersonView(db, { id, page, view }) {
@@ -141,6 +166,19 @@ export async function linkPerson(db, { id, clerkUserId }) {
141
166
  return row ?? null;
142
167
  }
143
168
 
169
+ // Fills a person's name from their sign-in account (Clerk) when Flow has
170
+ // none, so pickers show "Jane Citizen", not an email. A name Flow already
171
+ // holds stays.
172
+ export async function fillPersonName(db, { id, name }) {
173
+ const value = String(name ?? '').trim();
174
+ if (!value) return null;
175
+ const [row] = await db.update(people)
176
+ .set({ name: value, updatedAt: new Date() })
177
+ .where(and(eq(people.id, id), isNull(people.name)))
178
+ .returning();
179
+ return row ?? null;
180
+ }
181
+
144
182
  // While the installation has no owner, invite `email` as its owner (or make
145
183
  // that person the owner if they are already listed). Returns the row, or null
146
184
  // when an owner already exists.
@@ -13,6 +13,7 @@ import {
13
13
  publicPerson,
14
14
  removePerson,
15
15
  setPersonAppearance,
16
+ setPersonSavedViews,
16
17
  setPersonView,
17
18
  updatePerson,
18
19
  } from './people.js';
@@ -127,6 +128,16 @@ export function createSignInRouter({ settings, db, clerk, personAuth, defaultFam
127
128
  }
128
129
  });
129
130
 
131
+ // Each person's named views of a page and their default (ui/list-views.js).
132
+ router.put('/api/me/saved-views/:page', requireSession, express.json({ limit: '64kb' }), async (req, res) => {
133
+ try {
134
+ const saved = await setPersonSavedViews(db, { id: req.person.id, page: req.params.page, saved: req.body });
135
+ res.json({ ok: true, saved });
136
+ } catch (error) {
137
+ sendError(res, error, log);
138
+ }
139
+ });
140
+
130
141
  // Only an owner manages people.
131
142
  const people = express.Router();
132
143
  people.use(requireSession, requireOwner, express.json({ limit: '16kb' }));
@@ -1,5 +1,5 @@
1
1
  import { displayNameOf, googleWorkspaceOwner, primaryEmailOf, secondFactorVerified } from './clerk.js';
2
- import { findPersonByClerkId, findPersonByEmail, linkPerson, recordPendingPerson } from './people.js';
2
+ import { fillPersonName, findPersonByClerkId, findPersonByEmail, linkPerson, recordPendingPerson } from './people.js';
3
3
  import { DEFAULT_OWNER_MFA_MAX_AGE_MINUTES, isPlainEmail } from './settings.js';
4
4
 
5
5
  // Checks the Clerk session on every person request, then maps the Clerk user
@@ -60,10 +60,29 @@ export function createPersonAuth({
60
60
  // owner requests costs one Clerk call. Failures are never cached.
61
61
  const clerkUsers = new Map();
62
62
 
63
+ // Clerk users already checked for a name in this run, so a person whose
64
+ // sign-in has no name costs one Clerk call, not one per request.
65
+ const nameChecked = new Set();
66
+
67
+ // The person, with their Clerk name filled in when Flow has none. Never
68
+ // stops a sign-in: a Clerk error keeps the person as they are.
69
+ async function withName(person, clerkUser = null) {
70
+ if (person.name?.trim() || nameChecked.has(person.clerkUserId)) return person;
71
+ if (nameChecked.size > 1000) nameChecked.clear();
72
+ nameChecked.add(person.clerkUserId);
73
+ try {
74
+ const name = displayNameOf(clerkUser ?? await cachedClerkUser(person.clerkUserId));
75
+ return (name && await fillPersonName(db, { id: person.id, name })) || person;
76
+ } catch (error) {
77
+ log.warn?.(`[auth] could not read the name of Flow person ${person.id}: ${error.message}`);
78
+ return person;
79
+ }
80
+ }
81
+
63
82
  // { person } or { denial }.
64
83
  async function personFor(clerkUserId) {
65
84
  const linked = await findPersonByClerkId(db, clerkUserId);
66
- if (linked) return { person: linked };
85
+ if (linked) return { person: await withName(linked) };
67
86
 
68
87
  const clerkUser = await clerk.getUser(clerkUserId);
69
88
  const { email, verified } = primaryEmailOf(clerkUser);
@@ -76,7 +95,7 @@ export function createPersonAuth({
76
95
  const person = await linkPerson(db, { id: invited.id, clerkUserId });
77
96
  if (person) {
78
97
  log.info?.(`[auth] linked Clerk user ${clerkUserId} to Flow person ${person.id} (${person.role})`);
79
- return { person };
98
+ return { person: await withName(person, clerkUser) };
80
99
  }
81
100
  const again = await findPersonByClerkId(db, clerkUserId);
82
101
  return again ? { person: again } : { denial: 'email_linked_elsewhere' };
@@ -4,6 +4,7 @@ import {
4
4
  CUSTOM_FIELD_TYPES, integrationSettingEvents, integrationSettings, jobFieldEvents, jobFields, jobFieldSettings, jobFieldValues, people,
5
5
  } from '../db/schema.js';
6
6
  import { FlowError, notFound } from './errors.js';
7
+ import { personLabel } from '../ui/person.js';
7
8
 
8
9
  // Custom fields (docs/CUSTOM_FIELDS.md): fields a lab makes for its jobs in
9
10
  // Flow, like ClickUp's custom fields. An owner or admin makes them in
@@ -372,11 +373,11 @@ export async function listJobFieldValues(db, organisationId, { jobs = null } = {
372
373
  return rows.filter((r) => !wanted || wanted.has(`${r.sourceSystem}\u0000${r.sourceJobRef}`)).map(publicValue);
373
374
  }
374
375
 
375
- // The people a person field can name: every active person, by id and name
376
- // (the part of the email before @ when they have no name), A to Z.
376
+ // The people a person field can name: every active person, by id and label
377
+ // (full name, else the whole email: server/ui/person.js), A to Z.
377
378
  export async function choosablePeople(db) {
378
379
  const rows = await db.select({ id: people.id, name: people.name, email: people.email }).from(people).where(eq(people.status, 'active'));
379
- return rows.map((r) => ({ id: r.id, name: r.name?.trim() || r.email.replace(/@.*/, '') }))
380
+ return rows.map((r) => ({ id: r.id, name: personLabel(r) }))
380
381
  .sort((a, b) => a.name.localeCompare(b.name));
381
382
  }
382
383
 
@@ -57,7 +57,7 @@ export const TESTS = Object.freeze({
57
57
  number: ['more_than', 'at_most', 'is', 'is_not', 'is_empty'],
58
58
  money: ['more_than', 'at_most', 'is', 'is_not'],
59
59
  choice: ['is', 'is_not', 'is_one_of', 'is_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'],
60
+ text: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'contains', 'does_not_contain', 'starts_with', 'does_not_start_with', 'has_number', 'number_in_words', 'one_word', 'is_empty', 'is_not_empty'],
61
61
  boolean: ['is'],
62
62
  items: ['has', 'has_not'],
63
63
  share: ['is', 'is_not'],
@@ -65,7 +65,7 @@ export const TESTS = Object.freeze({
65
65
 
66
66
  export const ACTIONS = Object.freeze(['wait', 'skip', 'pending']);
67
67
  // Tests that take no value.
68
- const NO_VALUE = new Set(['is_empty', 'is_not_empty', 'has_number', 'one_word']);
68
+ const NO_VALUE = new Set(['is_empty', 'is_not_empty', 'has_number', 'number_in_words', 'one_word']);
69
69
 
70
70
  function readCondition(raw, index) {
71
71
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid(`condition ${index + 1} must be an object`);
@@ -131,6 +131,9 @@ function matches(condition, job, context) {
131
131
  if (!left) return false;
132
132
  if (test === 'is_not_one_of') return !value.some((v) => norm(v) === left);
133
133
  if (test === 'has_number') return /\d/.test(left);
134
+ // A part (between " - ") that is only a number is a client's reference,
135
+ // such as a patient number, so only a number beside words matches.
136
+ if (test === 'number_in_words') return left.split(' - ').some((part) => /\d/.test(part) && /\p{L}/u.test(part));
134
137
  if (test === 'one_word') return !left.includes(' ');
135
138
  if (test === 'contains') return left.includes(norm(value));
136
139
  if (test === 'does_not_contain') return !left.includes(norm(value));
@@ -182,12 +185,14 @@ export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}
182
185
  // The notes of the enabled pending rules that match one draft, as problems:
183
186
  // they put the draft on Pending (sending.js). `job` is the draft's job with
184
187
  // `reference`, the draft's Reference. A pending rule never stops a draft.
188
+ // `fields` names the fields the rule reads, so the invoice page marks the
189
+ // one to fix (the Reference box goes red).
185
190
  export function rulePending(rules, job, { customer = {}, now = new Date() } = {}) {
186
191
  const context = { customer, now };
187
192
  return rules
188
193
  .filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'pending')
189
194
  .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 }));
195
+ .map((r) => ({ code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null, fields: [...new Set(r.conditions.map((c) => c.field))] }));
191
196
  }
192
197
 
193
198
  // The starter rules made from the earlier fixed settings.
@@ -87,8 +87,9 @@ function jobLines(o, codes) {
87
87
  }
88
88
 
89
89
  // The two queues, oldest first. jobs: { id, clientName, practitioner,
90
- // jobDate, totalCents, possible }; invoices: { id, number, clientName,
91
- // issueDate, status, totalCents, possible }. `possible` counts the other
90
+ // jobDate, totalCents, status, sourceSystem, sourceJobRef, possible };
91
+ // invoices: { id, number, clientName, issueDate, status, totalCents,
92
+ // possible }. `possible` counts the other
92
93
  // side with the same client and total.
93
94
  export async function listReconcile(db, organisationId) {
94
95
  const jobs = await db.select({
@@ -97,6 +98,9 @@ export async function listReconcile(db, organisationId) {
97
98
  practitioner: billingObligations.practitioner,
98
99
  jobDate: billingObligations.jobDate,
99
100
  totalCents: billingObligations.totalCents,
101
+ status: billingObligations.sourceStatus,
102
+ sourceSystem: billingObligations.sourceSystem,
103
+ sourceJobRef: billingObligations.sourceJobRef,
100
104
  // An unclear accounting match: "Make invoice" settles it first.
101
105
  unclear: sql`${billingObligations.accountingState} = 'ambiguous'`.mapWith(Boolean),
102
106
  possible: sql`(select count(*) from invoices i
@@ -38,8 +38,9 @@ const LEASE_MS = 15 * 60 * 1000;
38
38
  // Outcomes that mean the accounting system holds the invoice.
39
39
  const SENT = new Set(['created', 'reconciled', 'existing', 'already_in_xero']);
40
40
 
41
- // `short` is the few words the Invoices list shows; `message` says how to fix it.
42
- const problem = (code, message, short = null) => ({ code, message, ...(short ? { short } : {}) });
41
+ // `short` is the few words the Invoices list shows; `message` says how to fix
42
+ // it. `extra` adds `fields` (the invoice fields to fix) and `line`.
43
+ const problem = (code, message, short = null, extra = {}) => ({ code, message, ...(short ? { short } : {}), ...extra });
43
44
 
44
45
  // Flow's own checks of one draft. `lines` are its invoice lines.
45
46
  export function draftProblems(invoice, lines) {
@@ -47,7 +48,7 @@ export function draftProblems(invoice, lines) {
47
48
  if (!lines.length) problems.push(problem('no_lines', 'The invoice has no lines.', 'No lines'));
48
49
  else if (invoice.totalCents <= 0) problems.push(problem('no_amount', 'The total is $0.00 or less.', 'Total $0'));
49
50
  for (const line of lines) {
50
- if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`));
51
+ if (!line.priceItemId && !line.accountCode) problems.push(problem('line_no_item', `Line ${line.position} has no price item.`, `Line ${line.position}: no item`, { fields: ['line_item'], line: line.position }));
51
52
  }
52
53
  return problems;
53
54
  }
@@ -77,7 +77,9 @@ async function get(url, { fetchImpl, retryDelayMs, as = "json" }) {
77
77
  throw new ReleaseUnavailable(lastError);
78
78
  }
79
79
 
80
- async function fromGitHub(channel, { slug, githubApi, fetchImpl, retryDelayMs }) {
80
+ // The release GitHub names for the channel, alone. A publish guard asks
81
+ // GitHub and npm separately, so the two must agree.
82
+ export async function fromGitHub(channel, { slug, githubApi, fetchImpl, retryDelayMs }) {
81
83
  if (channel === "stable") {
82
84
  const latest = await get(`${githubApi}/repos/${slug}/releases/latest`, { fetchImpl, retryDelayMs });
83
85
  const release = parseReleaseTag(latest?.tag_name);
@@ -95,7 +97,8 @@ async function fromGitHub(channel, { slug, githubApi, fetchImpl, retryDelayMs })
95
97
  return tags.at(-1);
96
98
  }
97
99
 
98
- async function fromNpm(channel, { npmRegistry, packageName, fetchImpl, retryDelayMs }) {
100
+ // The release npm's dist-tag names for the channel, alone.
101
+ export async function fromNpm(channel, { npmRegistry, packageName, fetchImpl, retryDelayMs }) {
99
102
  const metadata = await get(`${npmRegistry}/${packageName.replaceAll("/", "%2f")}`, { fetchImpl, retryDelayMs });
100
103
  const distTag = channel === "stable" ? "latest" : "beta";
101
104
  const version = metadata?.["dist-tags"]?.[distTag];
package/server/ui/app.js CHANGED
@@ -14,33 +14,19 @@ 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';
17
- import { lucide, renderCustomFields } from '/assets/ui/custom-fields.js';
17
+ import { renderCustomFields } from '/assets/ui/custom-fields.js';
18
18
  import { renderJobs } from '/assets/ui/jobs.js';
19
19
  import { renderReconcile } from '/assets/ui/reconcile.js';
20
20
  import { renderXeroImport } from '/assets/ui/xero-import.js';
21
21
  import { startMonitoring } from '/assets/ui/monitoring.js';
22
- import { openSearch } from '/assets/ui/search.js';
22
+ import { isSearchKey, 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
+ import { initialsOf, personLabel } from '/assets/ui/person.js';
25
+ import { startTips } from '/assets/ui/tip.js';
26
+ import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
25
27
 
26
28
  const $ = (id) => document.getElementById(id);
27
29
 
28
- // h('p', { class: 'x', onclick }, 'text', child) — children are text or nodes.
29
- function h(tag, attrs = {}, ...children) {
30
- const node = document.createElement(tag);
31
- for (const [key, value] of Object.entries(attrs ?? {})) {
32
- if (value == null || value === false) continue;
33
- if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
34
- else if (key === 'style' && typeof value === 'object') Object.assign(node.style, value);
35
- else node.setAttribute(key, value === true ? '' : String(value));
36
- }
37
- for (const child of children.flat()) {
38
- if (child == null || child === false) continue;
39
- node.append(child instanceof Node ? child : document.createTextNode(String(child)));
40
- }
41
- return node;
42
- }
43
-
44
30
  const ROLE_LABEL = { owner: 'Owner', admin: 'Admin', staff: 'Staff' };
45
31
  const ROLE_HINT = {
46
32
  owner: 'Manages people, connections and every record.',
@@ -137,97 +123,8 @@ async function flow(path, options = {}) {
137
123
  return body;
138
124
  }
139
125
 
140
- // Keeps a button off for `ms`, with the time left on it, then puts it back.
141
- function holdButton(button, ms) {
142
- if (!button || !(ms > 0)) return;
143
- clearInterval(button.fxHold);
144
- const label = button.dataset.label ?? button.textContent;
145
- button.dataset.label = label;
146
- const until = Date.now() + ms;
147
- const draw = () => {
148
- const left = Math.ceil((until - Date.now()) / 1000);
149
- if (left <= 0) {
150
- clearInterval(button.fxHold);
151
- button.textContent = label;
152
- delete button.dataset.label;
153
- button.removeAttribute('title');
154
- button.disabled = false;
155
- return;
156
- }
157
- const time = left < 60 ? `${left}s` : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
158
- button.disabled = true;
159
- button.textContent = `${label} · ${time}`;
160
- button.title = `Flow waits before it asks again, to protect the connected system's limits. Ready in ${time}.`;
161
- };
162
- draw();
163
- button.fxHold = setInterval(draw, 1000);
164
- }
165
-
166
126
  // ── Shared pieces ──────────────────────────────────────────────────────────
167
127
 
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 = [] } = {}) {
172
- return h('header', { class: 'fx-ph' },
173
- h('div', { class: 'fx-ph__text' },
174
- h('h1', { class: 'fx-title' }, title),
175
- count != null ? h('span', { class: 'fx-ph__count' }, count) : null,
176
- help ? helpLink(help) : null),
177
- actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
178
- }
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
-
190
- function notice(text, tone = 'info', title = null) {
191
- return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
192
- title ? h('strong', {}, title) : null,
193
- h('span', {}, text));
194
- }
195
-
196
- function toast(text, tone = 'info') {
197
- const region = $('toasts');
198
- const item = h('div', { class: 'fx-toast', 'data-tone': tone, role: 'status' }, text);
199
- region.append(item);
200
- setTimeout(() => item.remove(), 4200);
201
- }
202
-
203
- // A modal built on <dialog>: resolves with the button's value, or null.
204
- function dialog({ title, body, confirm, tone = 'primary' }) {
205
- return new Promise((resolve) => {
206
- const form = h('form', { method: 'dialog', class: 'fx-dialog__form' },
207
- h('h2', { class: 'fx-dialog__title' }, title),
208
- body,
209
- h('div', { class: 'fx-dialog__actions' },
210
- h('button', { class: 'fx-btn fx-btn--ghost', value: 'cancel', type: 'submit', formnovalidate: true }, 'Cancel'),
211
- h('button', { class: `fx-btn fx-btn--${tone}`, value: 'ok', type: 'submit' }, confirm)));
212
- const element = h('dialog', { class: 'fx-dialog' }, form);
213
- element.addEventListener('close', () => {
214
- const ok = element.returnValue === 'ok';
215
- const data = ok ? new FormData(form) : null;
216
- element.remove();
217
- resolve(ok ? data : null);
218
- });
219
- document.body.append(element);
220
- element.showModal();
221
- element.querySelector('input, select, button[value="ok"]')?.focus();
222
- });
223
- }
224
-
225
- function initials(person) {
226
- const source = (person.name || person.email || '?').replace(/@.*/, '');
227
- const parts = source.split(/[\s._-]+/).filter(Boolean);
228
- return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase();
229
- }
230
-
231
128
  function statusPill(person) {
232
129
  if (person.status === 'pending') return h('span', { class: 'fx-status', 'data-tone': 'warn' }, 'Waiting for approval');
233
130
  if (person.status === 'disabled') return h('span', { class: 'fx-status', 'data-tone': 'mute' }, 'Turned off');
@@ -268,7 +165,6 @@ const VIEWS = {
268
165
  const ICON_PATHS = {
269
166
  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"/>',
270
167
  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
168
  bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
273
169
  rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
274
170
  reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
@@ -362,7 +258,7 @@ function search() {
362
258
  }
363
259
 
364
260
  document.addEventListener('keydown', (event) => {
365
- if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
261
+ if (!isSearchKey(event)) return;
366
262
  if (document.body.dataset.state !== 'signed-in') return;
367
263
  event.preventDefault();
368
264
  search();
@@ -407,6 +303,8 @@ async function route() {
407
303
  }
408
304
 
409
305
  // Opens a path in place; the address changes, the page stays.
306
+ linksOpenWith((path) => go(path));
307
+
410
308
  function go(path, { replace = false } = {}) {
411
309
  if (path !== location.pathname + location.search) history[replace ? 'replaceState' : 'pushState'](null, '', path);
412
310
  route();
@@ -422,7 +320,7 @@ function navigate(event) {
422
320
  // The shared pieces a view module (invoicing.js) builds with. Its `go`
423
321
  // takes a path, or a link's click event.
424
322
  const ui = () => ({
425
- h, flow, toast, notice, pageHeader, sectionTitle, dialog, me, holdButton, tileCard, crumbs,
323
+ h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, dialog, me, holdButton, tileCard, tileSection, crumbs,
426
324
  go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
427
325
  });
428
326
 
@@ -548,28 +446,6 @@ async function refreshPeople(host) {
548
446
  ].filter(Boolean));
549
447
  }
550
448
 
551
- let peopleMenus = 0;
552
-
553
- // A row's ⋯ menu, as on the Custom fields page.
554
- function rowMenu(label, items) {
555
- const id = `fx-people-menu-${++peopleMenus}`;
556
- const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': label, popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false' }, lucide(h, 'ellipsis'));
557
- const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
558
- panel.addEventListener('toggle', (event) => {
559
- button.setAttribute('aria-expanded', String(event.newState === 'open'));
560
- if (event.newState !== 'open') return;
561
- const close = () => panel.hidePopover();
562
- panel.replaceChildren(...items.map((entry) => (entry === '-'
563
- ? h('div', { class: 'fx-pop__sep' })
564
- : h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', 'data-tone': entry.tone, onclick: () => { close(); entry.run(); } },
565
- h('span', { class: 'fx-pop__tick' }, lucide(h, entry.icon)), h('span', {}, entry.text)))));
566
- const at = button.getBoundingClientRect();
567
- panel.style.top = `${Math.round(at.bottom + 6)}px`;
568
- panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
569
- });
570
- return [button, panel];
571
- }
572
-
573
449
  function personRow(person, host) {
574
450
  const self = person.id === me.person.id;
575
451
  const act = async (fn, done) => {
@@ -583,9 +459,9 @@ function personRow(person, host) {
583
459
  };
584
460
  const patch = (body) => flow(`/api/people/${person.id}`, { method: 'PATCH', body: JSON.stringify(body) });
585
461
  const who = h('div', { class: 'fx-person' },
586
- h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initials(person)),
462
+ h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initialsOf(person)),
587
463
  h('span', { class: 'fx-person__text' },
588
- h('span', { class: 'fx-person__name' }, person.name || person.email, self ? h('span', { class: 'fx-tag' }, 'You') : null),
464
+ h('span', { class: 'fx-person__name' }, personLabel(person), self ? h('span', { class: 'fx-tag' }, 'You') : null),
589
465
  person.name ? h('span', { class: 'fx-person__email' }, person.email) : null));
590
466
  const role = self
591
467
  ? h('span', { title: ROLE_HINT[person.role] }, ROLE_LABEL[person.role])
@@ -728,27 +604,6 @@ function integrationTile(integration) {
728
604
  });
729
605
  }
730
606
 
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 },
737
- h('header', { class: 'fx-int-card__head' },
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));
751
- }
752
607
 
753
608
  // One integration's page (/integrations/<id>): its logo and maker, Connect
754
609
  // with each step shown as it happens, the setup steps, its settings, and the
@@ -959,22 +814,6 @@ function personSettingsSection(integration) {
959
814
  return section;
960
815
  }
961
816
 
962
- // One setting, laid out the same on every settings screen: a short name, one
963
- // line of smaller text under it, and the control on the right. Longer help
964
- // lives behind a link, not here. A wide control (a grid of choices) sits on
965
- // its own line under the name. `tone: 'danger'` marks a row that needs a person.
966
- function settingRow({ label, hint, control, wide = false, tone = null }) {
967
- const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
968
- const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
969
- return h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
970
- h('div', { class: 'fx-setting__text' },
971
- id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
972
- hint ? h('p', { class: 'fx-setting__hint' }, hint) : null),
973
- h('div', { class: 'fx-setting__control' }, control));
974
- }
975
- settingRow.next = 0;
976
-
977
- // The steps of a Connect, each ticked, running or crossed, with the time taken.
978
817
  function connectProgress({ steps, started }) {
979
818
  const seconds = Math.max(0, Math.round((Date.now() - started) / 1000));
980
819
  return h('div', { class: 'fx-progress', role: 'status' },