@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.
- package/README.md +1 -0
- package/VISION.md +10 -10
- package/npm-shrinkwrap.json +11 -11
- package/package.json +1 -1
- package/server/api/invoicing.js +89 -22
- package/server/auth/people.js +3 -1
- package/server/auth/routes.js +4 -1
- package/server/core/contacts.js +160 -0
- package/server/core/directory.js +4 -9
- package/server/core/invoice-rules.js +34 -4
- package/server/core/invoices.js +93 -1
- package/server/core/invoicing-rules.js +20 -3
- package/server/core/job-board.js +1 -1
- package/server/core/sending.js +141 -25
- 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/sentry/index.js +14 -7
- 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/alerts.js +2 -1
- package/server/ui/app.js +113 -28
- package/server/ui/bulk-bar.js +18 -5
- package/server/ui/columns.js +5 -2
- package/server/ui/contacts.js +260 -0
- package/server/ui/custom-fields.js +22 -7
- package/server/ui/flow.css +275 -24
- package/server/ui/invoicing.js +374 -70
- package/server/ui/job-board.js +10 -0
- package/server/ui/job-change.js +4 -3
- package/server/ui/job-view.js +3 -1
- package/server/ui/jobs.js +150 -19
- package/server/ui/kit.js +220 -45
- package/server/ui/list-views.js +50 -10
- package/server/ui/mobile.js +729 -0
- package/server/ui/monitoring.js +51 -0
- package/server/ui/nav-pins.js +44 -0
- package/server/ui/pager.js +57 -10
- package/server/ui/price-list.js +1 -1
- package/server/ui/price-reconcile.js +12 -12
- package/server/ui/reconcile.js +18 -17
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +2 -1
- package/server/ui/sidecar.js +106 -6
- package/server/ui/view-bar.js +22 -11
- 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,
|
|
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
|
-
// -
|
|
28
|
-
//
|
|
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
|
|
292
|
-
|
|
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
|
|
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
|
-
?
|
|
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),
|
package/server/ui/alerts.js
CHANGED
|
@@ -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
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
// by link
|
|
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',
|
|
157
|
-
'custom-fields': { label: 'Custom fields', icon: 'fields', place: 'more',
|
|
158
|
-
integrations: { label: 'Integrations', icon: 'plug', place: 'more',
|
|
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
|
-
|
|
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
|
|
366
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
};
|
package/server/ui/bulk-bar.js
CHANGED
|
@@ -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.
|
|
13
|
-
//
|
|
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
|
-
|
|
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 } : {}),
|
package/server/ui/columns.js
CHANGED
|
@@ -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.
|