@arcforgelabs/flow 2026.10.47 → 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 (38) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/server/api/invoicing.js +35 -8
  5. package/server/core/contacts.js +160 -0
  6. package/server/core/directory.js +4 -9
  7. package/server/core/invoices.js +84 -31
  8. package/server/core/sending.js +65 -3
  9. package/server/db/doctor.js +1 -1
  10. package/server/db/index.js +19 -0
  11. package/server/db/migrations/0021_flow_schema.sql +68 -0
  12. package/server/db/migrations/0022_contacts.sql +11 -0
  13. package/server/db/migrations/meta/0021_snapshot.json +4377 -0
  14. package/server/db/migrations/meta/0022_snapshot.json +4446 -0
  15. package/server/db/migrations/meta/_journal.json +14 -0
  16. package/server/db/schema.js +15 -0
  17. package/server/integrations/port.js +2 -0
  18. package/server/integrations/schema.js +3 -2
  19. package/server/integrations/setup.js +1 -0
  20. package/server/integrations/xero/catalogue.js +82 -14
  21. package/server/integrations/xero/client.js +37 -0
  22. package/server/integrations/xero/create-contact.js +126 -0
  23. package/server/integrations/xero/delete-invoice.js +104 -0
  24. package/server/integrations/xero/drafts.js +19 -8
  25. package/server/integrations/xero/index.js +9 -0
  26. package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
  27. package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
  28. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  29. package/server/integrations/xero/routes.js +21 -0
  30. package/server/integrations/xero/settings.js +3 -0
  31. package/server/ui/app.js +8 -3
  32. package/server/ui/contacts.js +260 -0
  33. package/server/ui/flow.css +40 -0
  34. package/server/ui/invoicing.js +159 -54
  35. package/server/ui/kit.js +28 -8
  36. package/server/ui/mobile.js +6 -3
  37. package/server/ui/nav-pins.js +5 -2
  38. package/server/ui/routes.js +1 -1
@@ -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),
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';
@@ -152,6 +153,7 @@ let me = null;
152
153
  const VIEWS = {
153
154
  jobs: { label: 'Jobs', icon: 'jobs', render: (main) => renderJobs(main, ui()) },
154
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) },
155
157
  reconcile: { label: 'Reconcile', icon: 'reconcile', render: (main, id) => renderReconcile(main, ui(), id) },
156
158
  'price-list': { label: 'Price list', icon: 'tag', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
157
159
  'invoicing-settings': { label: 'Rules', icon: 'rules', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
@@ -171,6 +173,7 @@ const VIEWS = {
171
173
  const ICON_PATHS = {
172
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"/>',
173
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"/>',
174
177
  bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
175
178
  rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
176
179
  reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
@@ -756,9 +759,10 @@ async function renderIntegration(main, id) {
756
759
  let input;
757
760
  if (field.kind === 'choice' || field.kind === 'onoff') {
758
761
  const choices = field.kind === 'onoff' ? [{ value: 'off', label: 'Off' }, { value: 'on', label: 'On' }] : field.choices;
759
- 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 },
760
764
  field.kind === 'choice' ? h('option', { value: '' }, 'Choose…') : null,
761
- 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)));
762
766
  } else if (field.secret) {
763
767
  input = h('input', { class: 'fx-input', type: 'password', autocomplete: 'off', disabled, placeholder: field.set ? 'Saved' : 'Paste it here' });
764
768
  } else {
@@ -781,7 +785,8 @@ async function renderIntegration(main, id) {
781
785
  for (const [name, { field, input }] of inputs) {
782
786
  const value = input.value.trim();
783
787
  if (field.secret) { if (value) out[name] = value; continue; }
784
- 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;
785
790
  }
786
791
  return out;
787
792
  };
@@ -0,0 +1,260 @@
1
+ // Contacts (/contacts, /contacts/<id>). Flow's own customers and suppliers
2
+ // (server/core/contacts.js, docs/CONTACTS.md). The list and the record work
3
+ // with no integration on. When Xero is connected, the record says whether
4
+ // that contact is in Xero and, for an owner or admin, offers Create.
5
+ import { initialsOf } from '/assets/ui/person.js';
6
+
7
+ const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CONTACTS.md';
8
+ const KINDS = [['customer', 'Customer'], ['supplier', 'Supplier']];
9
+
10
+ const field = (h, label, control) => h('label', { class: 'fx-field' }, h('span', { class: 'fx-label' }, label), control);
11
+ const input = (h, attrs) => h('input', { class: 'fx-input', autocomplete: 'off', ...attrs });
12
+
13
+ export async function renderContacts(main, ui, id) {
14
+ if (id) return renderContact(main, ui, id);
15
+ return renderContactList(main, ui);
16
+ }
17
+
18
+ async function renderContactList(main, ui) {
19
+ const { h, flow, toast, notice, dialog, pageHeader, go, me } = ui;
20
+ const canEdit = ['owner', 'admin'].includes(me?.person.role);
21
+ const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
22
+ const search = input(h, { type: 'search', 'aria-label': 'Search', placeholder: 'Search', class: 'fx-input fx-contacts__search' });
23
+ const kind = h('select', { class: 'fx-select', 'aria-label': 'Kind' },
24
+ h('option', { value: 'all', selected: true }, 'All'),
25
+ ...KINDS.map(([value, label]) => h('option', { value }, `${label}s`)));
26
+ const page = h('div', { class: 'fx-contacts' });
27
+ const add = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', hidden: !canEdit, onclick: () => createContact() }, 'New');
28
+ page.append(
29
+ pageHeader('Contacts', { help: { href: DOCS, label: 'How contacts work' }, actions: [add] }),
30
+ host,
31
+ );
32
+ main.append(page);
33
+
34
+ let rows = [];
35
+ const draw = () => {
36
+ const needle = search.value.trim().toLowerCase();
37
+ const shown = rows.filter((contact) => {
38
+ if (kind.value !== 'all' && contact.kind !== kind.value) return false;
39
+ if (!needle) return true;
40
+ return [contact.name, contact.email, contact.accountNumber].some((value) => String(value ?? '').toLowerCase().includes(needle));
41
+ });
42
+ const body = shown.length
43
+ ? h('table', { class: 'fx-table fx-people' },
44
+ h('thead', { class: 'fx-sr' }, h('tr', {}, h('th', {}, 'Name'), h('th', {}, 'Kind'), h('th', {}, 'Account'))),
45
+ h('tbody', {}, shown.map((contact) => h('tr', {},
46
+ h('td', {}, h('a', { class: 'fx-person', href: `/contacts/${contact.id}`, onclick: go },
47
+ h('span', { class: 'fx-avatar', 'aria-hidden': 'true' }, initialsOf(contact.name)),
48
+ h('span', { class: 'fx-person__text' },
49
+ h('span', { class: 'fx-person__name' }, contact.name, contact.active ? null : h('span', { class: 'fx-tag' }, 'Inactive')),
50
+ contact.email ? h('span', { class: 'fx-person__email' }, contact.email) : null))),
51
+ h('td', {}, KINDS.find(([value]) => value === contact.kind)?.[1] ?? contact.kind),
52
+ h('td', {}, contact.accountNumber ?? '')))))
53
+ : h('p', { class: 'fx-empty' }, rows.length ? 'Nothing matches.' : 'No contacts.');
54
+ host.replaceChildren(
55
+ h('div', { class: 'fx-contacts__tools' }, search, kind),
56
+ body,
57
+ );
58
+ };
59
+
60
+ search.addEventListener('input', draw);
61
+ kind.addEventListener('change', draw);
62
+
63
+ async function createContact() {
64
+ const name = input(h, { name: 'name', required: true, 'aria-label': 'Name' });
65
+ const kindInput = h('select', { class: 'fx-select', name: 'kind', 'aria-label': 'Kind' },
66
+ ...KINDS.map(([value, label]) => h('option', { value, selected: value === 'customer' }, label)));
67
+ const email = input(h, { name: 'email', type: 'email', 'aria-label': 'Email' });
68
+ const confirmed = await dialog({
69
+ title: 'New contact',
70
+ confirm: 'Create',
71
+ body: h('div', { class: 'fx-fields' },
72
+ field(h, 'Name', name),
73
+ field(h, 'Kind', kindInput),
74
+ field(h, 'Email', email)),
75
+ });
76
+ if (!confirmed) return;
77
+ try {
78
+ const body = await flow('/api/invoicing/contacts', {
79
+ method: 'POST',
80
+ body: JSON.stringify({
81
+ name: String(confirmed.get('name') ?? ''),
82
+ kind: String(confirmed.get('kind') ?? 'customer'),
83
+ email: String(confirmed.get('email') ?? '').trim() || null,
84
+ }),
85
+ });
86
+ go(`/contacts/${body.contact.id}`);
87
+ } catch (error) {
88
+ toast(error.message, 'danger');
89
+ }
90
+ }
91
+
92
+ try {
93
+ ({ contacts: rows } = await flow('/api/invoicing/contacts?kind=all&active=all&limit=200'));
94
+ draw();
95
+ } catch (error) {
96
+ host.replaceChildren(notice(error.message, 'danger'));
97
+ }
98
+ }
99
+
100
+ async function renderContact(main, ui, id) {
101
+ const { h, flow, toast, notice, pageHeader, crumbs, me } = ui;
102
+ const canEdit = ['owner', 'admin'].includes(me?.person.role);
103
+ const page = h('div', { class: 'fx-contacts' }, h('div', { class: 'fx-skeleton' }));
104
+ main.append(page);
105
+ let contact;
106
+ try {
107
+ ({ contact } = await flow(`/api/invoicing/contacts/${id}`));
108
+ } catch (error) {
109
+ page.replaceChildren(notice(error.message, 'danger'));
110
+ return;
111
+ }
112
+
113
+ const values = {
114
+ name: input(h, { value: contact.name ?? '' }),
115
+ kind: h('select', { class: 'fx-select', 'aria-label': 'Kind' },
116
+ ...KINDS.map(([value, label]) => h('option', { value, selected: value === contact.kind }, label))),
117
+ firstName: input(h, { value: contact.firstName ?? '' }),
118
+ lastName: input(h, { value: contact.lastName ?? '' }),
119
+ email: input(h, { type: 'email', value: contact.email ?? '' }),
120
+ phone: input(h, { value: contact.phone ?? '' }),
121
+ mobile: input(h, { value: contact.mobile ?? '' }),
122
+ accountNumber: input(h, { value: contact.accountNumber ?? '' }),
123
+ taxNumber: input(h, { value: contact.taxNumber ?? '' }),
124
+ taxRate: input(h, { value: contact.taxRate ?? '' }),
125
+ };
126
+ const address = contact.address ?? {};
127
+ const addressInputs = {
128
+ line1: input(h, { value: address.line1 ?? '', 'aria-label': 'Address line 1' }),
129
+ line2: input(h, { value: address.line2 ?? '', 'aria-label': 'Address line 2' }),
130
+ city: input(h, { value: address.city ?? '', 'aria-label': 'City' }),
131
+ region: input(h, { value: address.region ?? '', 'aria-label': 'Region' }),
132
+ postalCode: input(h, { value: address.postalCode ?? '', 'aria-label': 'Postal code' }),
133
+ country: input(h, { value: address.country ?? '', 'aria-label': 'Country' }),
134
+ };
135
+ const practitioners = h('textarea', {
136
+ class: 'fx-input', rows: '4', 'aria-label': 'Practitioners',
137
+ }, (contact.practitioners ?? []).map((person) => person.name).join('\n'));
138
+ const active = h('input', { type: 'checkbox', checked: contact.active });
139
+ for (const control of [...Object.values(values), ...Object.values(addressInputs), practitioners, active]) {
140
+ if (!canEdit) control.disabled = true;
141
+ }
142
+
143
+ const save = h('button', {
144
+ class: 'fx-btn fx-btn--primary', type: 'button', hidden: !canEdit,
145
+ onclick: async () => {
146
+ const addressBody = Object.fromEntries(Object.entries(addressInputs).map(([key, control]) => [key, control.value.trim()]));
147
+ const people = practitioners.value.split('\n').map((line) => line.trim()).filter(Boolean).map((name) => ({ name }));
148
+ save.disabled = true;
149
+ try {
150
+ const body = await flow(`/api/invoicing/contacts/${id}`, {
151
+ method: 'PATCH',
152
+ body: JSON.stringify({
153
+ name: values.name.value,
154
+ kind: values.kind.value,
155
+ firstName: values.firstName.value,
156
+ lastName: values.lastName.value,
157
+ email: values.email.value,
158
+ phone: values.phone.value,
159
+ mobile: values.mobile.value,
160
+ accountNumber: values.accountNumber.value,
161
+ taxNumber: values.taxNumber.value,
162
+ taxRate: values.taxRate.value,
163
+ address: Object.values(addressBody).some(Boolean) ? addressBody : null,
164
+ practitioners: people,
165
+ active: active.checked,
166
+ }),
167
+ });
168
+ toast('Saved.', 'success');
169
+ contact = body.contact;
170
+ } catch (error) {
171
+ toast(error.message, 'danger');
172
+ } finally {
173
+ save.disabled = false;
174
+ }
175
+ },
176
+ }, 'Save');
177
+
178
+ const xero = h('div');
179
+ page.replaceChildren(
180
+ crumbs(['/contacts', 'Contacts'], contact.name),
181
+ pageHeader(contact.name, { actions: [save] }),
182
+ h('section', { class: 'fx-card' },
183
+ h('div', { class: 'fx-contacts__grid' },
184
+ field(h, 'Name', values.name),
185
+ field(h, 'Kind', values.kind),
186
+ field(h, 'First name', values.firstName),
187
+ field(h, 'Last name', values.lastName),
188
+ field(h, 'Email', values.email),
189
+ field(h, 'Phone', values.phone),
190
+ field(h, 'Mobile', values.mobile),
191
+ field(h, 'Account', values.accountNumber),
192
+ field(h, 'Tax number', values.taxNumber),
193
+ field(h, 'Tax rate', values.taxRate),
194
+ h('div', { class: 'fx-contacts__wide fx-fields' },
195
+ field(h, 'Address', addressInputs.line1),
196
+ addressInputs.line2,
197
+ h('div', { class: 'fx-contacts__grid' },
198
+ field(h, 'City', addressInputs.city),
199
+ field(h, 'Region', addressInputs.region),
200
+ field(h, 'Postal code', addressInputs.postalCode),
201
+ field(h, 'Country', addressInputs.country))),
202
+ h('label', { class: 'fx-field fx-contacts__wide' },
203
+ h('span', { class: 'fx-label' }, 'Practitioners'),
204
+ practitioners),
205
+ h('label', { class: 'fx-check' }, active, 'Active'))),
206
+ xero,
207
+ );
208
+ void loadXero(ui, contact, canEdit, xero);
209
+ }
210
+
211
+ async function loadXero(ui, contact, canEdit, host) {
212
+ const { h, flow, toast, notice } = ui;
213
+ let context;
214
+ try {
215
+ context = await flow('/api/invoicing/context');
216
+ } catch {
217
+ return;
218
+ }
219
+ const organisationId = context.organisations?.[0]?.id;
220
+ if (!organisationId) return;
221
+ let match;
222
+ try {
223
+ match = await flow(`/api/xero/contacts/match?organisationId=${encodeURIComponent(organisationId)}&contactId=${encodeURIComponent(contact.id)}`);
224
+ } catch {
225
+ return;
226
+ }
227
+ if (match.state === 'off') return;
228
+ const create = match.offerCreate && canEdit
229
+ ? h('button', {
230
+ class: 'fx-btn fx-btn--primary', type: 'button', title: 'Create this client in Xero',
231
+ onclick: async (event) => {
232
+ const button = event.currentTarget;
233
+ const confirmed = await ui.dialog({
234
+ title: `Create ${contact.name} in Xero?`,
235
+ body: h('p', {}, 'Flow adds this client to Xero from the contact.'),
236
+ confirm: 'Create',
237
+ });
238
+ if (!confirmed) return;
239
+ button.disabled = true;
240
+ try {
241
+ await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: contact.id }) });
242
+ toast(`${contact.name} is in Xero.`, 'success');
243
+ host.replaceChildren(h('section', { class: 'fx-card' }, notice(`${contact.name} is in Xero.`, 'success')));
244
+ } catch (error) {
245
+ toast(error.message, error.status === 429 ? 'info' : 'danger');
246
+ if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
247
+ else button.disabled = false;
248
+ }
249
+ },
250
+ }, 'Create')
251
+ : null;
252
+ const text = match.state === 'matched'
253
+ ? `In Xero${match.name ? ` as ${match.name}` : ''}.`
254
+ : match.state === 'ambiguous'
255
+ ? `More than one Xero contact is named "${contact.name}".`
256
+ : `No Xero contact is named "${contact.name}".`;
257
+ host.replaceChildren(h('section', { class: 'fx-card fx-offer' },
258
+ notice(text, match.state === 'matched' ? 'success' : 'danger', match.state === 'matched' ? 'Xero.' : 'Xero.'),
259
+ create));
260
+ }
@@ -365,6 +365,7 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
365
365
  .fx-tile .fx-logo--icon { background: var(--fx-hover); color: var(--t-strong); box-shadow: inset 0 0 0 1px var(--fx-line); }
366
366
  .fx-tile h3 { overflow: hidden; color: var(--t-strong); font: 600 14.5px/1.35 var(--t-body); text-overflow: ellipsis; white-space: nowrap; }
367
367
  .fx-tile__meta { color: var(--t-dim); font-size: 12.5px; white-space: nowrap; }
368
+ .fx-tile__go { display: none; }
368
369
  .fx-logo--icon .fx-lucide { width: 22px; height: 22px; }
369
370
  .fx-logo img { width: 28px; height: 28px; object-fit: contain; }
370
371
  .fx-logo--lg { width: 52px; height: 52px; }
@@ -1398,6 +1399,26 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1398
1399
  [data-phone-layout="two-line"] tr.fx-table__group td { height: 32px; padding: 0; background: none; }
1399
1400
  [data-phone-layout="two-line"] tr.fx-table__group .fx-tick-cell { height: 32px; }
1400
1401
  [data-phone-layout="two-line"] tr.fx-table__group .fx-tick-cell .fx-tick { width: 44px; height: 44px; }
1402
+ /* Tiles: every card is one 56px row, as a list row: the mark, the name,
1403
+ the status and ›. The blurb and the button stay on the page it opens. */
1404
+ .fx-int-grid, .fx-tiles__grid { gap: 8px; }
1405
+ :is(.fx-int-card, .fx-tile)[data-phone-layout="one-line"] {
1406
+ display: flex; align-items: center; gap: 12px; box-sizing: border-box;
1407
+ height: 56px; padding: 0 8px 0 12px;
1408
+ }
1409
+ .fx-int-card[data-phone-layout="one-line"]:is(:hover, :focus-visible) { transform: none; box-shadow: none; }
1410
+ [data-phone-layout="one-line"] .fx-int-card__head { display: contents; }
1411
+ [data-phone-layout="one-line"] :is(.fx-int-card__blurb, .fx-int-card__btn) { display: none; }
1412
+ [data-phone-layout="one-line"] .fx-logo { width: 32px; height: 32px; border-radius: var(--t-radius); }
1413
+ [data-phone-layout="one-line"] .fx-logo img { width: 20px; height: 20px; }
1414
+ [data-phone-layout="one-line"] .fx-logo--icon .fx-lucide { width: 18px; height: 18px; }
1415
+ [data-phone-layout="one-line"] h3 {
1416
+ flex: 1; min-width: 0; overflow: hidden; font: 600 14.5px/1.35 var(--t-body);
1417
+ text-overflow: ellipsis; white-space: nowrap;
1418
+ }
1419
+ [data-phone-layout="one-line"] :is(.fx-status, .fx-tile__meta) { flex: none; }
1420
+ [data-phone-layout="one-line"] .fx-tile__go { display: inline-flex; flex: none; color: var(--t-dim); }
1421
+ [data-phone-layout="one-line"] .fx-tile__go .fx-lucide { width: 18px; height: 18px; }
1401
1422
  }
1402
1423
 
1403
1424
  @keyframes fx-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@@ -1910,3 +1931,22 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1910
1931
  .fx-xu-hour { flex: none; width: 10px; }
1911
1932
  .fx-xu__fold { padding: 12px; }
1912
1933
  }
1934
+
1935
+ /* Contacts (server/ui/contacts.js) and the pending-invoice Create offer.
1936
+ Phone targets stay on this page only, so other screenshots stay valid. */
1937
+ .fx-offer { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
1938
+ .fx-offer > .fx-notice { flex: 1 1 220px; }
1939
+ .fx-contacts { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
1940
+ .fx-contacts__tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 16px; border-bottom: 1px solid var(--fx-line-soft); }
1941
+ .fx-contacts__search { flex: 1 1 180px; max-width: 320px; }
1942
+ .fx-contacts .fx-card { padding: 16px 20px; }
1943
+ .fx-contacts__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
1944
+ .fx-contacts__wide { grid-column: 1 / -1; }
1945
+ .fx-contacts textarea.fx-input { min-height: 96px; padding: 8px 10px; resize: vertical; }
1946
+ .fx-contacts .fx-person { color: inherit; text-decoration: none; }
1947
+ @media (max-width: 639px) {
1948
+ .fx-contacts__grid { grid-template-columns: minmax(0, 1fr); }
1949
+ .fx-offer .fx-btn,
1950
+ .fx-contacts :is(.fx-btn, .fx-input, .fx-select, .fx-check) { min-height: 44px; }
1951
+ .fx-contacts .fx-help { min-width: 44px; min-height: 44px; }
1952
+ }