@arcforgelabs/flow 2026.10.47 → 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.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +11 -11
- package/package.json +1 -1
- package/server/api/invoicing.js +35 -8
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoices.js +84 -31
- package/server/core/sending.js +65 -3
- package/server/db/doctor.js +1 -1
- package/server/db/index.js +19 -0
- package/server/db/migrations/0021_flow_schema.sql +68 -0
- package/server/db/migrations/0022_contacts.sql +11 -0
- package/server/db/migrations/meta/0021_snapshot.json +4377 -0
- package/server/db/migrations/meta/0022_snapshot.json +4446 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +15 -0
- package/server/integrations/port.js +2 -0
- package/server/integrations/schema.js +3 -2
- package/server/integrations/seazona/sweep.js +11 -8
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/catalogue.js +82 -14
- package/server/integrations/xero/client.js +37 -0
- package/server/integrations/xero/create-contact.js +126 -0
- package/server/integrations/xero/delete-invoice.js +104 -0
- package/server/integrations/xero/drafts.js +19 -8
- package/server/integrations/xero/index.js +9 -0
- package/server/integrations/xero/migrations/0011_xero_invoice_deleted.sql +11 -0
- package/server/integrations/xero/migrations/0012_xero_contact_details.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/routes.js +21 -0
- package/server/integrations/xero/settings.js +3 -0
- package/server/ui/app.js +8 -3
- package/server/ui/contacts.js +260 -0
- package/server/ui/flow.css +40 -0
- package/server/ui/invoicing.js +159 -54
- package/server/ui/kit.js +28 -8
- package/server/ui/mobile.js +6 -3
- package/server/ui/nav-pins.js +5 -2
- package/server/ui/routes.js +1 -1
|
@@ -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),
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
+
}
|
package/server/ui/flow.css
CHANGED
|
@@ -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
|
+
}
|