@arcforgelabs/flow 2026.10.46 → 2026.10.48-1

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 (64) hide show
  1. package/README.md +1 -0
  2. package/VISION.md +10 -10
  3. package/npm-shrinkwrap.json +11 -11
  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/seazona/sweep.js +11 -8
  26. package/server/integrations/sentry/index.js +14 -7
  27. package/server/integrations/setup.js +1 -0
  28. package/server/integrations/xero/catalogue.js +82 -14
  29. package/server/integrations/xero/client.js +37 -0
  30. package/server/integrations/xero/create-contact.js +126 -0
  31. package/server/integrations/xero/delete-invoice.js +104 -0
  32. package/server/integrations/xero/drafts.js +19 -8
  33. package/server/integrations/xero/index.js +9 -0
  34. package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
  35. package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
  36. package/server/integrations/xero/migrations/meta/_journal.json +14 -0
  37. package/server/integrations/xero/routes.js +21 -0
  38. package/server/integrations/xero/settings.js +3 -0
  39. package/server/ui/alerts.js +2 -1
  40. package/server/ui/app.js +113 -28
  41. package/server/ui/bulk-bar.js +18 -5
  42. package/server/ui/columns.js +5 -2
  43. package/server/ui/contacts.js +260 -0
  44. package/server/ui/custom-fields.js +22 -7
  45. package/server/ui/flow.css +275 -24
  46. package/server/ui/invoicing.js +374 -70
  47. package/server/ui/job-board.js +10 -0
  48. package/server/ui/job-change.js +4 -3
  49. package/server/ui/job-view.js +3 -1
  50. package/server/ui/jobs.js +150 -19
  51. package/server/ui/kit.js +220 -45
  52. package/server/ui/list-views.js +50 -10
  53. package/server/ui/mobile.js +729 -0
  54. package/server/ui/monitoring.js +51 -0
  55. package/server/ui/nav-pins.js +44 -0
  56. package/server/ui/pager.js +57 -10
  57. package/server/ui/price-list.js +1 -1
  58. package/server/ui/price-reconcile.js +12 -12
  59. package/server/ui/reconcile.js +18 -17
  60. package/server/ui/routes.js +1 -1
  61. package/server/ui/saved-views.js +2 -1
  62. package/server/ui/sidecar.js +106 -6
  63. package/server/ui/view-bar.js +22 -11
  64. package/server/ui/xero-import.js +48 -19
@@ -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
+ }
@@ -15,6 +15,21 @@
15
15
 
16
16
  import { orderStatuses, statusRank } from '/assets/ui/job-board.js';
17
17
  import { sidecar } from '/assets/ui/sidecar.js';
18
+ import { assignPhoneSlots, phoneSlot } from './mobile.js';
19
+
20
+ // The manager row uses the same phone slots as a column list.
21
+ const FIELD_SLOTS = assignPhoneSlots([
22
+ { id: 'name', kind: 'text' },
23
+ { id: 'type', kind: 'text' },
24
+ { id: 'by', kind: 'person' },
25
+ { id: 'date', kind: 'date' },
26
+ { id: 'where', kind: 'text' },
27
+ { id: 'menu', kind: 'text', phone: 'end' },
28
+ ]);
29
+ const fieldSlot = (id) => {
30
+ const slot = phoneSlot(FIELD_SLOTS.find((column) => column.id === id));
31
+ return slot ? { 'data-phone-slot': slot } : {};
32
+ };
18
33
 
19
34
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
20
35
  const TYPE_LABELS = {
@@ -395,12 +410,12 @@ export async function renderCustomFields(main, ui) {
395
410
  });
396
411
  input.addEventListener('blur', () => { if (view.renaming === field.id) rename(field, input.value); });
397
412
  queueMicrotask(() => { input.focus(); input.select(); });
398
- return h('div', { class: 'fx-cfm__name', role: 'cell' }, input);
413
+ return h('div', { class: 'fx-cfm__name', role: 'cell', ...fieldSlot('name') }, input);
399
414
  }
400
415
  const editButton = field.archived || disabled || field.source ? null
401
416
  : h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'Edit', lucide(h, 'panel-right'));
402
417
  const viewButton = field.source ? h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'View', lucide(h, 'panel-right')) : null;
403
- return h('div', { class: 'fx-cfm__name', role: 'cell' },
418
+ return h('div', { class: 'fx-cfm__name', role: 'cell', ...fieldSlot('name') },
404
419
  h('div', { class: 'fx-cfm__title' },
405
420
  field.source ? lucide(h, 'lock', 'Read only') : null,
406
421
  h('span', { class: 'fx-cfm__label', title: field.name }, field.name), editButton, viewButton));
@@ -418,11 +433,11 @@ export async function renderCustomFields(main, ui) {
418
433
  });
419
434
  return h('div', { class: 'fx-cfm__row', role: 'row', 'data-id': field.id, 'data-open': sideFor === field.id ? 'true' : null, 'data-archived': field.archived ? 'true' : null },
420
435
  nameCell(field),
421
- h('div', { class: 'fx-cfm__type', role: 'cell' }, picker ? [picker.button, picker.panel] : h('span', { class: 'fx-cfm__icon', 'data-color': color }, icon)),
422
- h('div', { class: 'fx-cfm__by', role: 'cell' }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
423
- h('div', { class: 'fx-cfm__date', role: 'cell', title: field.source ? `${field.sourceLabel} connected` : null }, field.createdAt ? createdDay(field.createdAt) : h('span', { class: 'fx-dim' }, '—')),
424
- h('div', { class: 'fx-cfm__where', role: 'cell' }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
425
- h('div', { class: 'fx-cfm__end', role: 'cell' }, field.source ? null : rowMenu(field, picker)));
436
+ h('div', { class: 'fx-cfm__type', role: 'cell', ...fieldSlot('type') }, picker ? [picker.button, picker.panel] : h('span', { class: 'fx-cfm__icon', 'data-color': color }, icon)),
437
+ h('div', { class: 'fx-cfm__by', role: 'cell', ...fieldSlot('by') }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
438
+ h('div', { class: 'fx-cfm__date', role: 'cell', ...fieldSlot('date'), title: field.source ? `${field.sourceLabel} connected` : null }, field.createdAt ? createdDay(field.createdAt) : h('span', { class: 'fx-dim' }, '—')),
439
+ h('div', { class: 'fx-cfm__where', role: 'cell', ...fieldSlot('where') }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
440
+ h('div', { class: 'fx-cfm__end', role: 'cell', ...fieldSlot('menu') }, field.source ? null : rowMenu(field, picker)));
426
441
  }
427
442
 
428
443
  // One group: a header that folds it, its rows, and "Create … field". A