@arcforgelabs/flow 2026.10.46 → 2026.10.48

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.
Files changed (63) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +2 -2
  4. package/package.json +1 -1
  5. package/server/api/invoicing.js +89 -22
  6. package/server/auth/people.js +3 -1
  7. package/server/auth/routes.js +4 -1
  8. package/server/core/contacts.js +160 -0
  9. package/server/core/directory.js +4 -9
  10. package/server/core/invoice-rules.js +34 -4
  11. package/server/core/invoices.js +93 -1
  12. package/server/core/invoicing-rules.js +20 -3
  13. package/server/core/job-board.js +1 -1
  14. package/server/core/sending.js +141 -25
  15. package/server/db/doctor.js +1 -1
  16. package/server/db/index.js +19 -0
  17. package/server/db/migrations/0021_flow_schema.sql +68 -0
  18. package/server/db/migrations/0022_contacts.sql +11 -0
  19. package/server/db/migrations/meta/0021_snapshot.json +4377 -0
  20. package/server/db/migrations/meta/0022_snapshot.json +4446 -0
  21. package/server/db/migrations/meta/_journal.json +14 -0
  22. package/server/db/schema.js +15 -0
  23. package/server/integrations/port.js +2 -0
  24. package/server/integrations/schema.js +3 -2
  25. package/server/integrations/sentry/index.js +14 -7
  26. package/server/integrations/setup.js +1 -0
  27. package/server/integrations/xero/catalogue.js +82 -14
  28. package/server/integrations/xero/client.js +37 -0
  29. package/server/integrations/xero/create-contact.js +126 -0
  30. package/server/integrations/xero/delete-invoice.js +104 -0
  31. package/server/integrations/xero/drafts.js +19 -8
  32. package/server/integrations/xero/index.js +9 -0
  33. package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
  34. package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
  35. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  36. package/server/integrations/xero/routes.js +21 -0
  37. package/server/integrations/xero/settings.js +3 -0
  38. package/server/ui/alerts.js +2 -1
  39. package/server/ui/app.js +113 -28
  40. package/server/ui/bulk-bar.js +18 -5
  41. package/server/ui/columns.js +5 -2
  42. package/server/ui/contacts.js +260 -0
  43. package/server/ui/custom-fields.js +22 -7
  44. package/server/ui/flow.css +275 -24
  45. package/server/ui/invoicing.js +374 -70
  46. package/server/ui/job-board.js +10 -0
  47. package/server/ui/job-change.js +4 -3
  48. package/server/ui/job-view.js +3 -1
  49. package/server/ui/jobs.js +150 -19
  50. package/server/ui/kit.js +220 -45
  51. package/server/ui/list-views.js +50 -10
  52. package/server/ui/mobile.js +729 -0
  53. package/server/ui/monitoring.js +51 -0
  54. package/server/ui/nav-pins.js +44 -0
  55. package/server/ui/pager.js +57 -10
  56. package/server/ui/price-list.js +1 -1
  57. package/server/ui/price-reconcile.js +12 -12
  58. package/server/ui/reconcile.js +18 -17
  59. package/server/ui/routes.js +1 -1
  60. package/server/ui/saved-views.js +2 -1
  61. package/server/ui/sidecar.js +106 -6
  62. package/server/ui/view-bar.js +22 -11
  63. package/server/ui/xero-import.js +48 -19
@@ -5,7 +5,8 @@ import { getInvoice, listInvoiceVersions } from '../../core/invoices.js';
5
5
  import { sendModeFor } from '../../core/invoicing-rules.js';
6
6
  import { contacts as flowContacts, invoices as flowInvoices, priceItems } from '../../db/schema.js';
7
7
  import { readConnection } from './connections.js';
8
- import { itemsByCode, lastSuccessfulSyncAt, matchContact, normaliseName } from './catalogue.js';
8
+ import { itemsByCode, lastSuccessfulSyncAt, matchContactFor, normaliseName } from './catalogue.js';
9
+ import { writesAllowed } from './settings.js';
9
10
  import { xeroError } from './client.js';
10
11
  import { withXeroLane } from './governor.js';
11
12
  import { mirrorState } from './lookup.js';
@@ -24,8 +25,9 @@ import { mirrorState } from './lookup.js';
24
25
  // version is never changed, here or in Xero: Flow never updates a Xero
25
26
  // invoice. A revised invoice (version 2) goes to Xero only after the
26
27
  // earlier version's Xero invoice is deleted or voided there.
27
- // - Flow never creates a Xero contact. The customer must match one Xero
28
- // contact by name (the synced copy, catalogue.js).
28
+ // - The customer must match one Xero contact, by the identity-map link or by
29
+ // name (the synced copy, catalogue.js). When Create missing clients is on,
30
+ // a missing contact can be created from the Flow contact (create-contact.js).
29
31
  // - Before it creates, Flow asks Xero for invoices with the same reference.
30
32
  // One for the same contact and total is the same work: Flow records it and
31
33
  // creates nothing. One for the same contact at another total within 90
@@ -66,6 +68,13 @@ function xeroDate(value) {
66
68
 
67
69
  const problem = (code, message, extra = {}) => ({ code, message, ...extra });
68
70
 
71
+ function missingContact(name, { offerCreate }) {
72
+ const message = offerCreate
73
+ ? `No Xero contact is named "${name}". Create the client in Xero from this contact.`
74
+ : `No Xero contact is named "${name}". Add it in Xero, then sync.`;
75
+ return problem('xero_contact_missing', message, { short: 'Client not in Xero', ...(offerCreate ? { offerCreate: true } : {}) });
76
+ }
77
+
69
78
  // ---------------------------------------------------------------------------
70
79
  // The document to send
71
80
 
@@ -288,8 +297,9 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
288
297
  const problems = [];
289
298
  if (invoice.status === 'void') problems.push(problem('invoice_void', 'This invoice is void. Flow does not send it to Xero.'));
290
299
  if (invoice.status === 'draft') problems.push(problem('invoice_not_issued', 'This invoice is a draft. Issue it, then send it to Xero.'));
291
- const match = await matchContact(db, organisationId, document.contact.name);
292
- if (match.state === 'missing') problems.push(problem('xero_contact_missing', `No Xero contact is named "${document.contact.name}". Add it in Xero (Flow never creates Xero contacts), then sync.`, { short: 'Client not in Xero' }));
300
+ const match = await matchContactFor(db, organisationId, { contactId: invoice.contactId, name: document.contact.name });
301
+ const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
302
+ if (match.state === 'missing') problems.push(missingContact(document.contact.name, { offerCreate }));
293
303
  if (match.state === 'ambiguous') problems.push(problem('xero_contact_ambiguous', `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync.`, { short: 'Client twice in Xero' }));
294
304
  const items = await itemsByCode(db, organisationId, document.lines.map((l) => l.itemCode));
295
305
  const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
@@ -389,7 +399,7 @@ export async function checkDrafts(db, runtime, { organisationId, invoiceIds }) {
389
399
  for (const preview of invoices) {
390
400
  found.set(preview.invoiceId, preview.problems
391
401
  .filter((p) => p.blocking !== false && p.code !== 'invoice_not_issued')
392
- .map(({ code, message, short }) => ({ code, message, ...(short ? { short } : {}) })));
402
+ .map(({ code, message, short, offerCreate }) => ({ code, message, ...(short ? { short } : {}), ...(offerCreate ? { offerCreate: true } : {}) })));
393
403
  }
394
404
  }
395
405
  return found;
@@ -468,11 +478,12 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
468
478
  }
469
479
  }
470
480
 
471
- const match = await matchContact(db, organisationId, document.contact.name);
481
+ const match = await matchContactFor(db, organisationId, { contactId: invoice.contactId, name: document.contact.name });
472
482
  if (match.state !== 'matched') {
483
+ const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
473
484
  throw xeroError(match.state === 'missing' ? 'xero_contact_missing' : 'xero_contact_ambiguous',
474
485
  match.state === 'missing'
475
- ? `No Xero contact is named "${document.contact.name}". Add it in Xero (Flow never creates Xero contacts), then sync.`
486
+ ? missingContact(document.contact.name, { offerCreate }).message
476
487
  : `More than one Xero contact is named "${document.contact.name}". Merge or rename them in Xero, then sync.`,
477
488
  { status: 409 });
478
489
  }
@@ -1,6 +1,8 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { sql } from 'drizzle-orm';
3
3
  import { guardAlerts } from '../call-guard.js';
4
+ import { deleteXeroInvoice } from './delete-invoice.js';
5
+ import { createCustomer } from './create-contact.js';
4
6
  import { checkDrafts, documentFacts, pushInvoice, pushInvoices } from './drafts.js';
5
7
  import { readConnection } from './connections.js';
6
8
  import { createImportWorker } from './import.js';
@@ -41,6 +43,7 @@ export const XERO_SETUP = {
41
43
  { name: 'XERO_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
42
44
  { name: 'XERO_TENANT_NAME', label: 'Xero organisation name', help: 'Live mode connects only the organisation with this exact name.' },
43
45
  { name: 'XERO_ALLOW_LIVE_WRITES', label: 'Create draft invoices in live Xero', kind: 'onoff', help: 'Off: Flow only reads from Xero.' },
46
+ { name: 'XERO_CREATE_CONTACTS', label: 'Create missing clients', kind: 'onoff', default: 'on', help: 'On: a pending invoice whose client is not in Xero offers Create. Off: add the client in Xero, then sync.' },
44
47
  { name: 'XERO_WEBHOOK_KEY', label: 'Webhook key', secret: true, help: 'From the Xero app\'s Webhooks page. Empty: Flow reads Xero\'s changes every 15 minutes instead.' },
45
48
  ],
46
49
  derivedKeys: ['XERO_TOKEN_KEY'],
@@ -115,6 +118,9 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
115
118
  // One issued invoice to a Xero draft (drafts.js). The Flow invoice id
116
119
  // and version are the idempotency key.
117
120
  pushInvoice: ({ organisationId, invoiceId, pushedBy = null }) => pushInvoice(database, current(), { organisationId, invoiceId, pushedBy }),
121
+ // Read the linked Xero invoice, and delete it only while it is a draft
122
+ // (delete-invoice.js). The core voids in Flow after deleted or gone.
123
+ deleteInvoice: ({ organisationId, invoiceId, actor = null }) => deleteXeroInvoice(database, current(), { organisationId, invoiceId, actor }),
118
124
  // Several issued invoices, in batches of MAX_BATCH (drafts.js).
119
125
  pushInvoices: ({ organisationId, invoiceIds, pushedBy = null }) => pushInvoices(database, current(), { organisationId, invoiceIds, pushedBy }),
120
126
  // The Invoices page tab of each issued invoice (reconcile.js), from
@@ -141,6 +147,9 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
141
147
  readChanges: ({ organisationId }) => readChangesFirst(database, current(), organisationId),
142
148
  // Active Xero contacts, from Flow's copy (lookup.js).
143
149
  listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
150
+ // Create the Xero contact for one Flow contact, when that setting is on
151
+ // (create-contact.js). The Flow contact id is the idempotency key.
152
+ createCustomer: ({ organisationId, contactId, actor = null }) => createCustomer(database, current(), { organisationId, contactId, actor }),
144
153
  // Sales invoices from the mirror, with the mirror's state (lookup.js).
145
154
  findInvoices: ({ organisationId, ...query }) => findInvoices(database, organisationId, query),
146
155
  // Xero items, from Flow's copy, for the price list (lookup.js).
@@ -0,0 +1,11 @@
1
+ -- Who deleted a Xero draft from Flow, and when. The push keeps Xero's status
2
+ -- (DELETED or VOIDED). A link made on the Import page has no push, so it
3
+ -- keeps the same who and when. The Xero adapter's own table: the core does
4
+ -- not store this.
5
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "deleted_by" text;
6
+ --> statement-breakpoint
7
+ ALTER TABLE "xero_invoice_pushes" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;
8
+ --> statement-breakpoint
9
+ ALTER TABLE "xero_invoice_links" ADD COLUMN IF NOT EXISTS "deleted_by" text;
10
+ --> statement-breakpoint
11
+ ALTER TABLE "xero_invoice_links" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;
@@ -0,0 +1,16 @@
1
+ -- Billing fields on Flow's copy of a Xero contact, so a created client and
2
+ -- a synced client keep the same record (docs/CONTACTS.md). The Xero adapter's
3
+ -- own table: the core does not store this.
4
+ ALTER TABLE "xero_contacts" ADD COLUMN IF NOT EXISTS "first_name" text;
5
+ --> statement-breakpoint
6
+ ALTER TABLE "xero_contacts" ADD COLUMN IF NOT EXISTS "last_name" text;
7
+ --> statement-breakpoint
8
+ ALTER TABLE "xero_contacts" ADD COLUMN IF NOT EXISTS "phone" text;
9
+ --> statement-breakpoint
10
+ ALTER TABLE "xero_contacts" ADD COLUMN IF NOT EXISTS "mobile" text;
11
+ --> statement-breakpoint
12
+ ALTER TABLE "xero_contacts" ADD COLUMN IF NOT EXISTS "account_number" text;
13
+ --> statement-breakpoint
14
+ ALTER TABLE "xero_contacts" ADD COLUMN IF NOT EXISTS "tax_number" text;
15
+ --> statement-breakpoint
16
+ ALTER TABLE "xero_contacts" ADD COLUMN IF NOT EXISTS "address" jsonb;
@@ -78,6 +78,20 @@
78
78
  "when": 1792530000000,
79
79
  "tag": "0010_xero_call_usage",
80
80
  "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "7",
85
+ "when": 1792540000000,
86
+ "tag": "0011_xero_invoice_deleted",
87
+ "breakpoints": true
88
+ },
89
+ {
90
+ "idx": 12,
91
+ "version": "7",
92
+ "when": 1792550000000,
93
+ "tag": "0012_xero_contact_details",
94
+ "breakpoints": true
81
95
  }
82
96
  ]
83
97
  }
@@ -4,6 +4,7 @@ import { FlowError } from '../../core/errors.js';
4
4
  import { createActionGuard } from '../call-guard.js';
5
5
  import { billingObligations, organisations } from '../../db/schema.js';
6
6
  import { lastSync, listContacts, listItems, syncCatalogue } from './catalogue.js';
7
+ import { createCustomer, matchCustomer } from './create-contact.js';
7
8
  import { createXeroClient } from './client.js';
8
9
  import { describePush, listPushes, MAX_BATCH, previewDrafts, pushDrafts } from './drafts.js';
9
10
  import { recordXeroEvidence, recordXeroPayments } from './ledger.js';
@@ -27,6 +28,9 @@ import { messagePage as themedMessagePage } from '../../ui/page.js';
27
28
  // POST /disconnect owner: revokes and forgets the connection
28
29
  // POST /sync owner or admin: copies Xero contacts and items
29
30
  // GET /contacts?organisationId=&q= any signed-in person: the copy
31
+ // GET /contacts/match?organisationId=&contactId= any signed-in person:
32
+ // whether this Flow contact is in the copy
33
+ // POST /contacts owner or admin: create the Xero contact
30
34
  // GET /items?organisationId= any signed-in person: the copy
31
35
  // POST /invoices/preview any signed-in person: the Xero drafts Flow
32
36
  // would make, and what stops them (no Xero calls)
@@ -59,6 +63,7 @@ export const XERO_ACTIONS = Object.freeze({
59
63
  sync: { cooldownMs: 5 * 60_000 },
60
64
  recheck: { cooldownMs: 30_000 },
61
65
  push: { cooldownMs: 0, budget: false },
66
+ createContact: { cooldownMs: 30_000 },
62
67
  obligationCheck: { cooldownMs: 30_000 },
63
68
  paymentsSync: { cooldownMs: 5 * 60_000 },
64
69
  mirror: { cooldownMs: 2 * 60_000 },
@@ -272,6 +277,22 @@ export function createXeroRouter({ db, personAuth, runtime, log = console, butto
272
277
  res.json({ ok: true, contacts: await listContacts(db, organisationId, { q: typeof req.query.q === 'string' ? req.query.q : '' }) });
273
278
  }));
274
279
 
280
+ // Whether this Flow contact is a Xero contact, and whether Create is offered.
281
+ // No Xero calls: it reads Flow's copy.
282
+ router.get('/contacts/match', ...signedIn, handle(log, async (req, res) => {
283
+ const organisationId = await requireOrganisation(db, req.query.organisationId);
284
+ if (typeof req.query.contactId !== 'string' || !UUID.test(req.query.contactId)) throw new FlowError('invalid', 'contactId must be a Flow contact id');
285
+ res.json({ ok: true, ...(await matchCustomer(db, runtime, { organisationId, contactId: req.query.contactId })) });
286
+ }));
287
+
288
+ router.post('/contacts', ...manager, act('createContact', (req) => req.body?.contactId), handle(log, async (req, res) => {
289
+ const organisationId = await requireOrganisation(db, req.body?.organisationId);
290
+ if (typeof req.body?.contactId !== 'string' || !UUID.test(req.body.contactId)) throw new FlowError('invalid', 'contactId must be a Flow contact id');
291
+ const result = await createCustomer(db, runtime, { organisationId, contactId: req.body.contactId, actor: `person:${req.person.id}` });
292
+ log.info?.(`[xero] organisation ${organisationId}: person ${req.person.id} ${result.outcome} Xero contact ${result.contact.xeroContactId} for Flow contact ${req.body.contactId}`);
293
+ res.status(result.outcome === 'created' ? 201 : 200).json({ ok: true, ...result });
294
+ }));
295
+
275
296
  router.get('/items', ...signedIn, handle(log, async (req, res) => {
276
297
  const organisationId = await requireOrganisation(db, req.query.organisationId);
277
298
  res.json({ ok: true, items: await listItems(db, organisationId) });
@@ -171,6 +171,9 @@ export function resolveXeroSettings(env = process.env) {
171
171
  // Free text on Xero records (references, line descriptions) is the lab's
172
172
  // own data, and the Import matches on it. On unless the lab turns it off.
173
173
  keepReference: readOnOff(env, 'XERO_KEEP_REFERENCE', true),
174
+ // On unless the lab turns it off: a pending invoice whose client is not
175
+ // in Xero offers to create that client (docs/CONTACTS.md).
176
+ createContacts: readOnOff(env, 'XERO_CREATE_CONTACTS', true),
174
177
  mirrorPageSize: Math.min(readPositiveInteger(env, 'XERO_MIRROR_PAGE_SIZE', DEFAULT_MIRROR_PAGE_SIZE), MAX_MIRROR_PAGE_SIZE),
175
178
  // The share of the day's calls the mirror leaves for drafts and people.
176
179
  mirrorReserveFraction: readFraction(env, 'XERO_MIRROR_RESERVE_FRACTION', 0.3),
@@ -33,8 +33,9 @@ export function alertStrip(ui, page, { onTab = () => {}, scope = null, onLoad =
33
33
  let action = null;
34
34
  if (first.action?.href && first.action.href !== location.pathname) action = h('a', { class: 'fx-alerts__action', href: first.action.href, onclick: go }, first.action.label);
35
35
  else if (first.action?.tab) action = h('button', { class: 'fx-alerts__action', type: 'button', onclick: () => onTab(first.action.tab) }, first.action.label);
36
+ const line = first.moneyAtRiskCents > 0 ? `${first.title} ${money.format(first.moneyAtRiskCents / 100)} at risk.` : first.title;
36
37
  element.replaceChildren(...[
37
- h('p', { class: 'fx-alerts__text' },
38
+ h('p', { class: 'fx-alerts__text', title: line },
38
39
  first.title,
39
40
  first.moneyAtRiskCents > 0 ? h('span', { class: 'fx-alerts__money' }, ` ${money.format(first.moneyAtRiskCents / 100)} at risk.`) : null),
40
41
  action,
package/server/ui/app.js CHANGED
@@ -10,6 +10,7 @@ import { getAppearance, onAppearanceChange, setAppearance, loadFonts } from '/as
10
10
  import { flowClerkAppearance } from '/assets/ui/clerk-appearance.js';
11
11
  import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/assets/theme/themes.js';
12
12
  import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
13
+ import { renderContacts } from '/assets/ui/contacts.js';
13
14
  import { automationSection, renderAutomations } from '/assets/ui/automations.js';
14
15
  import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
15
16
  import { renderPriceList } from '/assets/ui/price-list.js';
@@ -25,6 +26,7 @@ import { alertStrip } from '/assets/ui/alerts.js';
25
26
  import { initialsOf, personLabel } from '/assets/ui/person.js';
26
27
  import { startTips } from '/assets/ui/tip.js';
27
28
  import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
29
+ import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
28
30
 
29
31
  const $ = (id) => document.getElementById(id);
30
32
 
@@ -143,19 +145,21 @@ function roleSelect(value, attrs = {}) {
143
145
 
144
146
  let me = null;
145
147
 
146
- // Where each view sits: the sidebar, in this order, daily work first (no
147
- // `place`); the sidebar's More menu (`place: 'more'`, `more` names its Lucide
148
- // icon); the account menu (`place: 'account'`). `sidebar: false` views open
149
- // by link and Search only.
148
+ // Where each view sits, in sidebar order, daily work first. No `place`: a
149
+ // sidebar page, pinned until the person unpins it. `place: 'more'`: a sidebar
150
+ // page that starts in More until they pin it. `place: 'account'`: the account
151
+ // menu. `sidebar: false`: opened by a link or Search only. Jobs and Invoices
152
+ // cannot be unpinned (nav-pins.js).
150
153
  const VIEWS = {
151
154
  jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
152
155
  invoices: { label: 'Invoices', icon: 'invoices', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
156
+ contacts: { label: 'Contacts', icon: 'contacts', render: (main, id) => renderContacts(main, ui(), id) },
153
157
  reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
154
158
  'price-list': { label: 'Price list', icon: 'tag', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
155
159
  'invoicing-settings': { label: 'Rules', icon: 'rules', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
156
- automations: { label: 'Automations', icon: 'bolt', place: 'more', more: 'zap', render: (main) => renderAutomations(main, ui()) },
157
- 'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more', more: 'form-input', render: (main) => renderCustomFields(main, ui()) },
158
- integrations: { label: 'Integrations', icon: 'plug', place: 'more', more: 'plug', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
160
+ automations: { label: 'Automations', icon: 'bolt', place: 'more', render: (main) => renderAutomations(main, ui()) },
161
+ 'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more', render: (main) => renderCustomFields(main, ui()) },
162
+ integrations: { label: 'Integrations', icon: 'plug', place: 'more', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
159
163
  people: { label: 'People', icon: 'people', place: 'account', ownerOnly: true, render: renderPeople },
160
164
  // Not in the sidebar: "Manage account" in the account menu opens the same
161
165
  // Clerk profile. The page stays for links and Search.
@@ -169,6 +173,7 @@ const VIEWS = {
169
173
  const ICON_PATHS = {
170
174
  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"/>',
171
175
  invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
176
+ 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"/>',
172
177
  bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
173
178
  rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
174
179
  reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
@@ -301,11 +306,7 @@ async function route() {
301
306
  const [view, id] = currentView();
302
307
  const path = id ? `/${view}/${id}` : `/${view}`;
303
308
  if (location.pathname !== path) history.replaceState(null, '', path);
304
- for (const link of document.querySelectorAll('a[data-nav]')) {
305
- link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
306
- }
307
- // More shows as current while one of its pages is open.
308
- document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', VIEWS[view].place === 'more' ? 'page' : 'false');
309
+ markNavCurrent(view);
309
310
  main.replaceChildren();
310
311
  main.className = 'fx-main';
311
312
  document.title = `${VIEWS[view].label} · Flow`;
@@ -359,26 +360,108 @@ function renderSignedOut() {
359
360
  });
360
361
  }
361
362
 
363
+ // Sidebar pages this person may open. Account pages stay in the account menu.
364
+ function sidebarViews() {
365
+ return Object.entries(VIEWS).filter(([, view]) => view.sidebar !== false && view.place !== 'account' && (!view.ownerOnly || me?.person.role === 'owner'));
366
+ }
367
+
368
+ // Null until the shell reads the account. Jobs and Invoices are always in it.
369
+ let pinned = null;
370
+ let pinEpoch = 0;
371
+ let pinWrite = Promise.resolve();
372
+ let morePanel = null;
373
+
374
+ function pinSet() {
375
+ if (!pinned) {
376
+ pinned = new Set(me?.views?.nav?.pinned ?? NAV_DEFAULT);
377
+ for (const name of NAV_LOCKED) pinned.add(name);
378
+ }
379
+ return pinned;
380
+ }
381
+
382
+ function navLink(name, view) {
383
+ return h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label));
384
+ }
385
+
386
+ // More is the current page while the open page is a sidebar page that is
387
+ // not pinned, so it has no row of its own.
388
+ function markNavCurrent(view = currentView()[0]) {
389
+ for (const link of document.querySelectorAll('a[data-nav]')) {
390
+ link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
391
+ }
392
+ const shown = [...document.querySelectorAll('a[data-nav]')].some((link) => link.dataset.nav === view);
393
+ const inSidebar = sidebarViews().some(([name]) => name === view);
394
+ document.querySelector('[data-nav="more"]')?.setAttribute('aria-current', inSidebar && !shown ? 'page' : 'false');
395
+ }
396
+
397
+ function syncSidebar() {
398
+ const nav = $('nav');
399
+ const moreButton = nav?.querySelector('[data-nav="more"]');
400
+ if (!moreButton) return;
401
+ for (const link of [...nav.querySelectorAll('a[data-nav]')]) link.remove();
402
+ const links = sidebarViews().filter(([name]) => pinSet().has(name)).map(([name, view]) => navLink(name, view));
403
+ if (links.length) moreButton.before(...links);
404
+ morePanel?.refresh?.();
405
+ drawMenu();
406
+ markNavCurrent();
407
+ }
408
+
409
+ function moreItems() {
410
+ const pins = pinSet();
411
+ return sidebarViews().map(([name, view]) => {
412
+ const on = pins.has(name);
413
+ const locked = NAV_LOCKED.includes(name);
414
+ return {
415
+ icon: icon(view.icon),
416
+ text: view.label,
417
+ run: () => { closeMenu(); go(`/${name}`); },
418
+ trailing: locked
419
+ ? { icon: 'pin', label: 'Pinned', pressed: true, locked: true }
420
+ : { icon: 'pin', label: on ? 'Unpin' : 'Pin', pressed: on, run: () => togglePin(name) },
421
+ };
422
+ });
423
+ }
424
+
425
+ function togglePin(name) {
426
+ if (NAV_LOCKED.includes(name)) return;
427
+ const next = new Set(pinSet());
428
+ if (next.has(name)) next.delete(name);
429
+ else next.add(name);
430
+ for (const locked of NAV_LOCKED) next.add(locked);
431
+ const epoch = ++pinEpoch;
432
+ pinned = next;
433
+ syncSidebar();
434
+ const body = { pinned: [...next] };
435
+ pinWrite = pinWrite.then(() => savePins(body, epoch), () => savePins(body, epoch));
436
+ }
437
+
438
+ async function savePins(body, epoch) {
439
+ try {
440
+ const saved = await flow('/api/me/views/nav', { method: 'PUT', body: JSON.stringify(body) });
441
+ if (epoch !== pinEpoch) return;
442
+ pinned = new Set(saved.view.pinned);
443
+ me.views = { ...(me.views ?? {}), nav: saved.view };
444
+ syncSidebar();
445
+ } catch (error) {
446
+ if (epoch !== pinEpoch) return;
447
+ pinned = new Set(me?.views?.nav?.pinned ?? NAV_DEFAULT);
448
+ for (const locked of NAV_LOCKED) pinned.add(locked);
449
+ syncSidebar();
450
+ toast(error.message, 'danger');
451
+ }
452
+ }
453
+
362
454
  function renderShell() {
363
455
  document.body.dataset.state = 'signed-in';
364
456
  const nav = $('nav');
365
- const items = [];
366
- const more = [];
367
- for (const [name, view] of Object.entries(VIEWS)) {
368
- if (view.ownerOnly && me?.person.role !== 'owner') continue;
369
- if (view.sidebar === false || view.place === 'account') continue;
370
- if (view.place === 'more') more.push({ icon: view.more, text: view.label, run: () => { closeMenu(); go(`/${name}`); } });
371
- else items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
372
- }
373
- // The rest of the setup pages, behind More (the shared ⋯ menu).
374
- const [moreButton, morePanel] = rowMenu('More', more, { trigger: [icon('more'), h('span', { class: 'fx-nav__label' }, 'More')], className: 'fx-nav__more', align: 'start' });
457
+ const [moreButton, panel] = rowMenu('More', moreItems, { trigger: [icon('more'), h('span', { class: 'fx-nav__label' }, 'More')], className: 'fx-nav__more', align: 'start', place: 'side' });
458
+ morePanel = panel;
375
459
  Object.assign(moreButton.dataset, { nav: 'more', label: 'More' });
376
- items.push(moreButton, morePanel);
377
460
  // "Report a problem" sits at the foot of the menu, above the account chip,
378
461
  // while Sentry and its report form are on (monitoring.js).
379
462
  const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
380
463
  icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
381
- nav.replaceChildren(...items, report);
464
+ nav.replaceChildren(moreButton, panel, report);
382
465
  // Folded or narrow, the logo is the sidebar button: it shows the mark and
383
466
  // turns into the sidebar icon under the pointer or keyboard focus.
384
467
  const mark = h('span', { class: 'fx-toggle__mark', 'aria-hidden': 'true' });
@@ -387,7 +470,7 @@ function renderShell() {
387
470
  sidebar.classList.add('fx-toggle__icon');
388
471
  $('menu-toggle').replaceChildren(mark, sidebar);
389
472
  $('menu-toggle').onclick = toggleMenu;
390
- drawMenu();
473
+ syncSidebar();
391
474
  // The sidebar foot shows who is signed in: photo or initial, then the name
392
475
  // (the email when the account has no name). It opens the account menu.
393
476
  // People (owners) and Appearance live in the account menu.
@@ -676,9 +759,10 @@ async function renderIntegration(main, id) {
676
759
  let input;
677
760
  if (field.kind === 'choice' || field.kind === 'onoff') {
678
761
  const choices = field.kind === 'onoff' ? [{ value: 'off', label: 'Off' }, { value: 'on', label: 'On' }] : field.choices;
679
- input = h('select', { class: 'fx-select', disabled },
762
+ const fallback = field.kind === 'onoff' ? (field.default === 'on' || field.default === 'off' ? field.default : 'off') : '';
763
+ input = h('select', { class: 'fx-select', disabled, 'data-fallback': fallback },
680
764
  field.kind === 'choice' ? h('option', { value: '' }, 'Choose…') : null,
681
- choices.map((c) => h('option', { value: c.value, selected: (field.value ?? (field.kind === 'onoff' ? 'off' : '')) === c.value }, c.label)));
765
+ choices.map((c) => h('option', { value: c.value, selected: (field.value ?? fallback) === c.value }, c.label)));
682
766
  } else if (field.secret) {
683
767
  input = h('input', { class: 'fx-input', type: 'password', autocomplete: 'off', disabled, placeholder: field.set ? 'Saved' : 'Paste it here' });
684
768
  } else {
@@ -701,7 +785,8 @@ async function renderIntegration(main, id) {
701
785
  for (const [name, { field, input }] of inputs) {
702
786
  const value = input.value.trim();
703
787
  if (field.secret) { if (value) out[name] = value; continue; }
704
- if (value !== (field.value ?? (field.kind === 'onoff' ? 'off' : ''))) out[name] = value || null;
788
+ const fallback = input.dataset.fallback ?? (field.kind === 'onoff' ? 'off' : '');
789
+ if (value !== (field.value ?? fallback)) out[name] = value || null;
705
790
  }
706
791
  return out;
707
792
  };
@@ -9,16 +9,19 @@
9
9
  // bar for a screen reader ("Ticked invoices", "Ticked jobs"). state = { count, onClear,
10
10
  // groups: [[action]], more: [{ head, actions: [action] }] }; an action is
11
11
  // { icon, label, title?, run, primary?, iconOnly?, tone? }. title is the
12
- // long name, for the hover tip and the More menu. A count of 0 hides the
13
- // bar; Esc clears the ticks while it shows.
12
+ // long name, for the hover tip and the More menu. run(working) may call
13
+ // working('Sending') once the step really starts (after its dialog): the
14
+ // pressed button then shows a spinner and that word until run ends. A count
15
+ // of 0 hides the bar; Esc clears the ticks while it shows.
14
16
  import { lucide } from '/assets/ui/custom-fields.js';
17
+ import { applyPhoneRule } from './mobile.js';
15
18
 
16
19
  let barCount = 0;
17
20
 
18
21
  export function bulkBar(ui, { label = 'Ticked invoices' } = {}) {
19
22
  const { h } = ui;
20
23
  const id = `fx-bulk-more-${++barCount}`;
21
- const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': label, hidden: true });
24
+ const element = applyPhoneRule(h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': label, hidden: true }), 'bulkBar');
22
25
  const panel = h('div', { class: 'fx-pop fx-bulk__more', id, popover: 'auto', role: 'menu' });
23
26
  let current = null;
24
27
  let busy = false;
@@ -27,14 +30,24 @@ export function bulkBar(ui, { label = 'Ticked invoices' } = {}) {
27
30
  if (busy) return;
28
31
  busy = true;
29
32
  element.setAttribute('aria-busy', 'true');
30
- try { await action.run(); } finally {
33
+ // The step is on its way: its button (on the bar, or More's) spins.
34
+ const working = (word) => {
35
+ const pressed = [...element.querySelectorAll('.fx-bulk__act')].find((b) => b.dataset.action === action.label);
36
+ if (!pressed) return;
37
+ pressed.dataset.busy = '';
38
+ pressed.firstChild?.replaceWith(h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }));
39
+ const text = pressed.querySelector('.fx-bulk__word');
40
+ if (text && word) text.textContent = `${word}…`;
41
+ };
42
+ try { await action.run(working); } finally {
31
43
  busy = false;
32
44
  element.removeAttribute('aria-busy');
45
+ if (current) draw(current);
33
46
  }
34
47
  };
35
48
 
36
49
  const button = (action) => h('button', {
37
- type: 'button',
50
+ type: 'button', 'data-action': action.label,
38
51
  class: `fx-bulk__act${action.primary ? ' fx-bulk__act--primary' : ''}${action.iconOnly ? ' fx-bulk__act--icon' : ''}`,
39
52
  ...(action.tone ? { 'data-tone': action.tone } : {}),
40
53
  ...(action.iconOnly ? { 'aria-label': action.title ?? action.label, 'data-tip': action.title ?? action.label } : action.title ? { title: action.title } : {}),
@@ -31,6 +31,7 @@
31
31
  // Jobs and on Invoices, where it is the invoice's job's value).
32
32
  import { lucide } from '/assets/ui/custom-fields.js';
33
33
  import { endWidth, LEAD_WIDTH, MAX_WIDTH, minWidth, moveColumn, TAIL_WIDTH, tableWidth, widthOf } from '/assets/ui/job-view.js';
34
+ import { applyPhoneRule, assignPhoneSlots, phoneSlot } from './mobile.js';
34
35
  import { initialsOf } from '/assets/ui/person.js';
35
36
 
36
37
  const fullDate = (iso) => new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' });
@@ -81,10 +82,12 @@ export function fieldColumn(h, field, { id, valueOf, people }) {
81
82
  // A row's cells for the shown columns: a class per kind, the label for
82
83
  // the phone layout, the full text as the cell's name on hover.
83
84
  export function rowCells(h, columns, row) {
84
- return columns.map((c) => {
85
+ return assignPhoneSlots(columns).map((c) => {
86
+ const slot = phoneSlot(c);
85
87
  const td = h('td', {
86
88
  class: [`fx-c-${c.kind}`, c.num ? 'fx-num' : null, c.className].filter(Boolean).join(' '),
87
89
  'data-label': c.label,
90
+ ...(slot ? { 'data-phone-slot': slot } : {}),
88
91
  }, c.cell(row));
89
92
  // A cell stays on one line and ends in "…" when the column is narrow
90
93
  // (flow.css, .fx-table--sized); the whole text shows on hover.
@@ -173,7 +176,7 @@ export function columnTable(h, config) {
173
176
  h('colgroup', {}, leadCols, cols, endCol),
174
177
  h('thead', {}, h('tr', {}, lead, heads, tail)),
175
178
  rows);
176
- const box = h('div', { class: 'fx-table-scroll' }, node);
179
+ const box = applyPhoneRule(h('div', { class: 'fx-table-scroll' }, node), 'columnTable');
177
180
  // The width the columns need; past the window the table scrolls
178
181
  // sideways. A browser shares spare width among fixed columns, so the
179
182
  // end column is sized here, and again when the window changes size.