@arcforgelabs/flow 2026.11.5 → 2026.11.7
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/invoicing.js +5 -2
- package/server/app.js +1 -1
- package/server/core/automations.js +4 -1
- package/server/core/invoice-rules.js +64 -15
- package/server/core/invoicing-rules.js +50 -6
- package/server/core/job-board.js +3 -2
- package/server/core/ledger.js +10 -2
- package/server/core/pipeline.js +5 -2
- package/server/core/price-list.js +41 -12
- package/server/core/sending.js +3 -2
- package/server/core/sync-now.js +38 -0
- package/server/db/migrations/0029_job_remake.sql +1 -0
- package/server/db/migrations/meta/0029_snapshot.json +5069 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +3 -0
- package/server/integrations/port.js +3 -0
- package/server/integrations/seazona/feed.js +1 -0
- package/server/integrations/seazona/history/index.js +5 -2
- package/server/integrations/seazona/index.js +44 -0
- package/server/integrations/seazona/lanes.js +3 -0
- package/server/integrations/seazona/ledger.js +1 -0
- package/server/integrations/seazona/map.js +2 -0
- package/server/integrations/seazona/migrations/0008_order_remake.sql +1 -0
- package/server/integrations/seazona/migrations/meta/0008_snapshot.json +1526 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
- package/server/integrations/seazona/scheduler.js +13 -2
- package/server/integrations/seazona/schema.js +2 -0
- package/server/integrations/seazona/settings.js +3 -0
- package/server/integrations/seazona/store.js +14 -0
- package/server/integrations/seazona/sweep.js +13 -3
- package/server/integrations/sentry/index.js +9 -4
- package/server/integrations/setup.js +21 -1
- package/server/integrations/xero/catalogue.js +40 -0
- package/server/integrations/xero/drafts.js +16 -5
- package/server/integrations/xero/index.js +35 -0
- package/server/integrations/xero/lookup.js +11 -4
- package/server/integrations/xero/settings.js +8 -0
- package/server/integrations/xero/webhooks.js +4 -3
- package/server/ui/app.js +64 -3
- package/server/ui/flow.css +2 -1
- package/server/ui/kit.js +3 -2
- package/server/ui/monitoring.js +63 -3
- package/server/ui/price-list.js +5 -2
- package/skills/flow/SKILL.md +31 -1
|
@@ -85,14 +85,14 @@ export function createSeazonaScheduler({
|
|
|
85
85
|
timer?.unref?.();
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
async function tick() {
|
|
88
|
+
async function tick(options = {}) {
|
|
89
89
|
timer = null;
|
|
90
90
|
if (stopped) return;
|
|
91
91
|
const startedAt = new Date(Number(now())).toISOString();
|
|
92
92
|
const turn = { abandoned: false };
|
|
93
93
|
const thisRun = (async () => {
|
|
94
94
|
try {
|
|
95
|
-
const result = await run();
|
|
95
|
+
const result = await run(options);
|
|
96
96
|
const failed = Object.entries(result.results).filter(([, lane]) => !lane.ok);
|
|
97
97
|
last = { at: startedAt, ok: result.ok, lanes: Object.keys(result.results), failed: failed.map(([lane]) => lane) };
|
|
98
98
|
report(result.results);
|
|
@@ -135,6 +135,17 @@ export function createSeazonaScheduler({
|
|
|
135
135
|
nextRunAt = null;
|
|
136
136
|
await running;
|
|
137
137
|
},
|
|
138
|
+
// Sync now (server/core/sync-now.js): a person asks for a run now, with
|
|
139
|
+
// `options` (the lanes to read). While a run is going it starts nothing
|
|
140
|
+
// and answers 'running'. Otherwise the waiting run moves to now; the
|
|
141
|
+
// schedule goes on from its end as usual.
|
|
142
|
+
runNow(options = {}) {
|
|
143
|
+
if (stopped) return 'stopped';
|
|
144
|
+
if (running) return 'running';
|
|
145
|
+
if (timer) timers.clearTimeout(timer);
|
|
146
|
+
tick(options);
|
|
147
|
+
return 'started';
|
|
148
|
+
},
|
|
138
149
|
status() {
|
|
139
150
|
return { running: Boolean(running), nextRunAt, lastRun: last, stuckSince };
|
|
140
151
|
},
|
|
@@ -74,6 +74,8 @@ export const seazonaOrders = pgTable('seazona_orders', {
|
|
|
74
74
|
lines: jsonb('lines').notNull().default([]),
|
|
75
75
|
subtotalCents: cents('subtotal_cents'),
|
|
76
76
|
patientReference: text('patient_reference'),
|
|
77
|
+
// Seazona's isRemake on the order: a remake of earlier work.
|
|
78
|
+
remake: boolean('remake').notNull().default(false),
|
|
77
79
|
// Issue #12: Seazona answered 404. The row and its history stay; the order
|
|
78
80
|
// reads as "Deleted in Seazona" and is out of billing. A later successful
|
|
79
81
|
// read clears it.
|
|
@@ -206,6 +206,9 @@ export function resolveSeazonaSettings(env = process.env) {
|
|
|
206
206
|
// order number, for "Open in Seazona" on a stage move. Optional; one
|
|
207
207
|
// without {ref} is ignored.
|
|
208
208
|
orderUrlTemplate: orderUrlTemplate(env.SEAZONA_ORDER_URL),
|
|
209
|
+
// "Include in Sync now" (server/core/sync-now.js): on unless the lab
|
|
210
|
+
// turns it off.
|
|
211
|
+
syncNow: !['0', 'false', 'off', 'no'].includes(text(env.SEAZONA_SYNC_NOW).toLowerCase()),
|
|
209
212
|
};
|
|
210
213
|
|
|
211
214
|
if (mode === 'live' || mode === 'sandbox') {
|
|
@@ -81,6 +81,20 @@ export async function touchSynced(db, mirror, keys, { at }) {
|
|
|
81
81
|
await db.update(table).set({ lastSyncedAt: at }).where(inArray(table[key], keys));
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// The remake flag of orders whose other list fields did not change, from the
|
|
85
|
+
// list rows (`flags`, Map(id -> boolean)): no detail read is needed. A change
|
|
86
|
+
// counts as a change of the order, so the billing feed reads it again.
|
|
87
|
+
export async function syncRemakeFlags(db, flags, { at }) {
|
|
88
|
+
const ids = [...flags.keys()];
|
|
89
|
+
if (!ids.length) return 0;
|
|
90
|
+
const values = ids.map((id) => flags.get(id) === true);
|
|
91
|
+
const { rowCount } = await db.execute(sql`
|
|
92
|
+
update seazona_orders o set remake = f.remake, last_changed_at = ${at}
|
|
93
|
+
from unnest(${sql.param(ids)}::text[], ${sql.param(values)}::boolean[]) as f(id, remake)
|
|
94
|
+
where o.id = f.id and o.remake is distinct from f.remake`);
|
|
95
|
+
return rowCount ?? 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
84
98
|
// Issue #12: Seazona answered 404. Keep the row and its history, mark it, and
|
|
85
99
|
// refresh its sync time so it never comes first in the rotation again. An
|
|
86
100
|
// order seen in a list but never stored is kept from that list row.
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
readOrderFailures,
|
|
18
18
|
readQuarantinedRecords,
|
|
19
19
|
recordRecordFailure,
|
|
20
|
+
syncRemakeFlags,
|
|
20
21
|
touchSynced,
|
|
21
22
|
upsertRecords,
|
|
22
23
|
} from './store.js';
|
|
@@ -129,8 +130,15 @@ function laneReadsFrom(state, sync) {
|
|
|
129
130
|
return Number.isFinite(cursorMs) ? cursorMs - sync.cursorOverlapMs : null;
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
|
|
133
|
+
// What an orders cycle reads from each list row. 2: the remake flag
|
|
134
|
+
// (Seazona's isRemake, migration 0008). A cycle that finished before Flow
|
|
135
|
+
// kept the flag does not count, so the next run starts a new one and old
|
|
136
|
+
// orders get their flag without waiting out SEAZONA_SWEEP_CYCLE_DAYS.
|
|
137
|
+
export const ORDER_CYCLE_READS = 2;
|
|
138
|
+
|
|
139
|
+
function isDue(progress, at, sync, kind) {
|
|
133
140
|
if (progress?.cycleStartedAt) return true;
|
|
141
|
+
if (kind === 'orders' && (progress?.reads ?? 1) < ORDER_CYCLE_READS) return true;
|
|
134
142
|
const finished = Date.parse(progress?.lastFinishedAt ?? '');
|
|
135
143
|
return !Number.isFinite(finished) || at.getTime() - finished >= sync.sweepCycleMs;
|
|
136
144
|
}
|
|
@@ -216,6 +224,8 @@ async function sweepOrderDay(ctx, day, progress, helpers, found) {
|
|
|
216
224
|
}
|
|
217
225
|
}
|
|
218
226
|
await touchSynced(db, 'orders', unchanged, { at });
|
|
227
|
+
// The remake flag is on the list row: kept with no detail read.
|
|
228
|
+
await syncRemakeFlags(db, new Map(unchanged.map((id) => [id, listed.get(id)?.isRemake === true])), { at });
|
|
219
229
|
|
|
220
230
|
// Held for this day, left out of the list, and not seen this cycle.
|
|
221
231
|
for (const row of held) {
|
|
@@ -250,7 +260,7 @@ async function sweepOrders(ctx, cursor, helpers, found) {
|
|
|
250
260
|
progress.days.shift();
|
|
251
261
|
progress.checkedDays += 1;
|
|
252
262
|
}
|
|
253
|
-
cursor.orders = { lastFinishedAt: at.toISOString(), checkedDays: progress.checkedDays };
|
|
263
|
+
cursor.orders = { lastFinishedAt: at.toISOString(), checkedDays: progress.checkedDays, reads: ORDER_CYCLE_READS };
|
|
254
264
|
}
|
|
255
265
|
|
|
256
266
|
// Clients and invoices: the whole list, then the held records it left out.
|
|
@@ -498,7 +508,7 @@ export async function runSweep(ctx, helpers) {
|
|
|
498
508
|
try {
|
|
499
509
|
for (const kind of SWEEP_KINDS) {
|
|
500
510
|
if (budget.left <= 0) break;
|
|
501
|
-
if (!isDue(cursor[kind], at, sync)) continue;
|
|
511
|
+
if (!isDue(cursor[kind], at, sync, kind)) continue;
|
|
502
512
|
if (kind === 'orders') await sweepOrders(sweepCtx, cursor, helpers, found);
|
|
503
513
|
else await sweepWholeList(sweepCtx, kind, cursor, laneStates, helpers, found);
|
|
504
514
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as SentryNode from '@sentry/node';
|
|
2
2
|
import { isReplitWorkspace } from '../../core/host.js';
|
|
3
|
-
import { scrubSpan } from '../../ui/monitoring.js';
|
|
3
|
+
import { scrubEvent, scrubSpan } from '../../ui/monitoring.js';
|
|
4
4
|
|
|
5
5
|
// The Sentry adapter (docs/SENTRY.md). It sends the errors Flow logs, the
|
|
6
6
|
// browser's errors and problem reports, and performance traces from both
|
|
@@ -48,6 +48,7 @@ export const SENTRY_SETUP = {
|
|
|
48
48
|
{ name: 'SENTRY_ENVIRONMENT', label: 'Environment', advanced: true, help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
|
|
49
49
|
{ name: 'SENTRY_CLIENT', label: 'Client', advanced: true, help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
|
|
50
50
|
{ name: 'SENTRY_FEEDBACK', label: 'Report button', kind: 'onoff', default: 'on', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
|
|
51
|
+
{ name: 'SENTRY_SCREENSHOT', label: 'Screenshot', kind: 'onoff', default: 'off', help: 'On: a report can add a screenshot. A screenshot shows the screen as it is, with names and prices, so it is off by default.' },
|
|
51
52
|
{ name: 'SENTRY_REPLAY', label: 'Replay', kind: 'onoff', default: 'on', help: 'On: each report and each browser error keeps up to the last minute of the page, as a replay with every word hidden.' },
|
|
52
53
|
{ name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', advanced: true, help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
|
|
53
54
|
],
|
|
@@ -61,6 +62,7 @@ export function readSentrySettings(env) {
|
|
|
61
62
|
// labelled apart so they never read as the live app's.
|
|
62
63
|
const environment = String(env.SENTRY_ENVIRONMENT ?? '').trim() || (isReplitWorkspace(env) ? 'workspace' : 'production');
|
|
63
64
|
const feedback = String(env.SENTRY_FEEDBACK ?? 'on').trim() !== 'off';
|
|
65
|
+
const screenshot = String(env.SENTRY_SCREENSHOT ?? 'off').trim() === 'on';
|
|
64
66
|
const replay = String(env.SENTRY_REPLAY ?? 'on').trim() !== 'off';
|
|
65
67
|
const rate = String(env.SENTRY_TRACES_SAMPLE_RATE ?? '').trim();
|
|
66
68
|
const tracesSampleRate = rate === '' ? DEFAULT_TRACES_SAMPLE_RATE : Number(rate);
|
|
@@ -72,7 +74,7 @@ export function readSentrySettings(env) {
|
|
|
72
74
|
// Every event says which product and, when set, which client it is from,
|
|
73
75
|
// so one support desk can serve many installations.
|
|
74
76
|
const tags = client ? { product: 'flow', client } : { product: 'flow' };
|
|
75
|
-
return { dsn, environment, feedback, replay, tracesSampleRate, tags };
|
|
77
|
+
return { dsn, environment, feedback, screenshot, replay, tracesSampleRate, tags };
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
// No trace for the host's health check: it is not a person waiting.
|
|
@@ -105,8 +107,10 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
105
107
|
environment: settings.environment,
|
|
106
108
|
release: release ?? undefined,
|
|
107
109
|
initialScope: { tags: settings.tags },
|
|
108
|
-
// Flow sends what it logs
|
|
110
|
+
// Flow sends what it logs, with private words masked (scrubEvent);
|
|
111
|
+
// no request bodies, cookies or IP addresses.
|
|
109
112
|
sendDefaultPii: false,
|
|
113
|
+
beforeSend: scrubEvent,
|
|
110
114
|
// A request the browser traced keeps the browser's choice, so a
|
|
111
115
|
// click and the server work it caused stay one trace.
|
|
112
116
|
tracesSampler: ({ attributes, inheritOrSampleWith }) => (
|
|
@@ -139,12 +143,13 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
139
143
|
// meant for browsers.
|
|
140
144
|
browserMonitoring() {
|
|
141
145
|
if (!settings) return null;
|
|
142
|
-
const { tracesSampleRate, replay } = settings;
|
|
146
|
+
const { tracesSampleRate, replay, screenshot } = settings;
|
|
143
147
|
return {
|
|
144
148
|
dsn: settings.dsn,
|
|
145
149
|
environment: settings.environment,
|
|
146
150
|
release,
|
|
147
151
|
feedback: settings.feedback,
|
|
152
|
+
screenshot,
|
|
148
153
|
replay,
|
|
149
154
|
tags: settings.tags,
|
|
150
155
|
tracesSampleRate,
|
|
@@ -2,6 +2,8 @@ import { eq, sql } from 'drizzle-orm';
|
|
|
2
2
|
import express from 'express';
|
|
3
3
|
import { FlowError } from '../core/errors.js';
|
|
4
4
|
import { deriveKey, openSecret, sealSecret } from '../core/secrets.js';
|
|
5
|
+
import { startSyncNow, syncNowStatus } from '../core/sync-now.js';
|
|
6
|
+
import { createActionGuard } from './call-guard.js';
|
|
5
7
|
import { integrationSettingEvents, integrationSettings, organisations } from '../db/schema.js';
|
|
6
8
|
|
|
7
9
|
// Integration setup in Settings (docs/INTEGRATIONS.md, "Set up in Flow"). An
|
|
@@ -183,8 +185,11 @@ export async function saveSettings(db, { adapter, secrets, actor, enabled, value
|
|
|
183
185
|
|
|
184
186
|
// GET /api/integrations (owner): every adapter with settings, its state and
|
|
185
187
|
// what is set. PUT /api/integrations/:id (owner): save and apply.
|
|
186
|
-
export function createSetupRouter({ db, registry, personAuth, secrets, baseEnv = process.env, log = console }) {
|
|
188
|
+
export function createSetupRouter({ db, registry, personAuth, secrets, baseEnv = process.env, log = console, buttonCooldowns = true }) {
|
|
187
189
|
const router = express.Router();
|
|
190
|
+
// Sync now can call Xero at a press, so it is an action (docs/INTEGRATIONS.md,
|
|
191
|
+
// "Button presses"): one a minute, for all and for each integration alone.
|
|
192
|
+
const actions = createActionGuard({ integration: 'sync-now', actions: { syncNow: { cooldownMs: 60_000 } }, off: !buttonCooldowns });
|
|
188
193
|
const owner = [personAuth.requireSession, personAuth.requireRole('owner')];
|
|
189
194
|
// A change needs a verified owner sign-in, as connecting Xero does.
|
|
190
195
|
const verifiedOwner = [personAuth.requireSession, personAuth.requireOwner];
|
|
@@ -205,6 +210,21 @@ export function createSetupRouter({ db, registry, personAuth, secrets, baseEnv =
|
|
|
205
210
|
} catch (error) { next(error); }
|
|
206
211
|
});
|
|
207
212
|
|
|
213
|
+
// Sync now (server/core/sync-now.js): what the button shows, and the
|
|
214
|
+
// press. Reads only; the reads stay inside each call guard.
|
|
215
|
+
router.get('/sync-now', ...owner, async (_req, res, next) => {
|
|
216
|
+
try {
|
|
217
|
+
res.json({ ok: true, integrations: await syncNowStatus(registry) });
|
|
218
|
+
} catch (error) { next(error); }
|
|
219
|
+
});
|
|
220
|
+
router.post('/sync-now', ...owner, express.json({ limit: '4kb' }), actions.route('syncNow', { key: (req) => req.body?.only ?? 'all' }), async (req, res, next) => {
|
|
221
|
+
try {
|
|
222
|
+
const only = req.body?.only ?? null;
|
|
223
|
+
if (only !== null && !registry.get(only)) return res.status(404).json({ ok: false, code: 'not_found', error: 'No such integration' });
|
|
224
|
+
res.json({ ok: true, results: await startSyncNow(registry, { only, actor: req.person.id, log }) });
|
|
225
|
+
} catch (error) { next(error); }
|
|
226
|
+
});
|
|
227
|
+
|
|
208
228
|
router.put('/:id', ...verifiedOwner, express.json({ limit: '32kb' }), async (req, res) => {
|
|
209
229
|
const adapter = registry.get(req.params.id);
|
|
210
230
|
if (!adapter?.setup) return res.status(404).json({ ok: false, code: 'not_found', error: 'No such integration' });
|
|
@@ -223,6 +223,46 @@ export async function itemsByCode(db, organisationId, codes) {
|
|
|
223
223
|
return new Map(rows.map((row) => [row.code, itemRow(row)]));
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// The Xero item each Flow price code sends as. A Flow item linked to a Xero
|
|
227
|
+
// item (the price list's link, by Xero's ItemID, so a code changed in Xero
|
|
228
|
+
// still finds it) sends as that item, under Xero's own code. A link whose
|
|
229
|
+
// Xero item has left Xero's list is `linkMissing`: never fall back to the
|
|
230
|
+
// Flow code then. A Flow item with no link sends as the Xero item with the
|
|
231
|
+
// same code. Codes with neither are absent from the map.
|
|
232
|
+
export async function xeroItemsForCodes(db, organisationId, codes) {
|
|
233
|
+
const wanted = [...new Set(codes.filter(Boolean))];
|
|
234
|
+
if (!wanted.length) return new Map();
|
|
235
|
+
const { rows } = await db.execute(sql`
|
|
236
|
+
select p.code as flow_code, l.external_id as link_id, x.*
|
|
237
|
+
from unnest(${sql.param(wanted)}::text[]) as w(code)
|
|
238
|
+
left join price_items p on p.organisation_id = ${organisationId} and p.code = w.code
|
|
239
|
+
left join lateral (
|
|
240
|
+
select external_id from price_item_links pl
|
|
241
|
+
where pl.organisation_id = ${organisationId} and pl.price_item_id = p.id and pl.system = 'xero'
|
|
242
|
+
order by pl.created_at desc limit 1) l on true
|
|
243
|
+
left join xero_items x on x.organisation_id = ${organisationId}
|
|
244
|
+
and (case when l.external_id is not null then x.xero_item_id = l.external_id else x.code = w.code end)`);
|
|
245
|
+
const found = new Map();
|
|
246
|
+
for (const row of rows) {
|
|
247
|
+
const code = row.flow_code;
|
|
248
|
+
if (!code) continue;
|
|
249
|
+
if (row.link_id && !row.xero_item_id) found.set(code, { linkMissing: true, xeroItemId: row.link_id });
|
|
250
|
+
else if (row.xero_item_id) found.set(code, { ...itemRow(row), linked: Boolean(row.link_id) });
|
|
251
|
+
}
|
|
252
|
+
// A code with no Flow price item (an older document) still matches by code.
|
|
253
|
+
const rest = wanted.filter((c) => !found.has(c));
|
|
254
|
+
if (rest.length) for (const [code, item] of await itemsByCode(db, organisationId, rest)) found.set(code, item);
|
|
255
|
+
return found;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The Flow codes that send as a Xero item that sells: the rule field "Every
|
|
259
|
+
// item is in accounting" (invoice-rules.js) reads them. A gone link, a
|
|
260
|
+
// missing item and an item not set to be sold do not count.
|
|
261
|
+
export async function sellingCodes(db, organisationId, codes) {
|
|
262
|
+
const items = await xeroItemsForCodes(db, organisationId, codes);
|
|
263
|
+
return [...items].filter(([, item]) => !item.linkMissing && item.isSold !== false).map(([code]) => code);
|
|
264
|
+
}
|
|
265
|
+
|
|
226
266
|
// The Xero contact for a Flow contact name: exactly one active contact with
|
|
227
267
|
// the same name, or why not. A missing name can be created from the Flow
|
|
228
268
|
// contact when that setting is on (create-contact.js). A doubled name is
|
|
@@ -5,7 +5,7 @@ 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 {
|
|
8
|
+
import { lastSuccessfulSyncAt, xeroItemsForCodes, matchContactFor, normaliseName } from './catalogue.js';
|
|
9
9
|
import { writesAllowed } from './settings.js';
|
|
10
10
|
import { xeroError } from './client.js';
|
|
11
11
|
import { withXeroLane } from './governor.js';
|
|
@@ -162,8 +162,19 @@ export function buildDraft(document, { settings, contact, items, sendMode = 'acc
|
|
|
162
162
|
TaxType: settings.taxTypes[line.taxCode],
|
|
163
163
|
};
|
|
164
164
|
if (line.itemCode) {
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
// The Xero item this Flow code sends as (catalogue.js, xeroItemsForCodes):
|
|
166
|
+
// the linked item under Xero's own code, else the same code.
|
|
167
|
+
const item = items.get(line.itemCode);
|
|
168
|
+
if (item?.linkMissing) {
|
|
169
|
+
problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is linked to a Xero item that is no longer in Xero. Link it again in Price list, then sync.`, { itemCode: line.itemCode, short: `${line.itemCode} link gone` }));
|
|
170
|
+
} else if (!item) {
|
|
171
|
+
problems.push(problem('xero_item_missing', `Line ${line.position}: item ${line.itemCode} is not in Xero. Add it in Xero, then sync.`, { itemCode: line.itemCode, short: `${line.itemCode} not in Xero` }));
|
|
172
|
+
} else if (item.isSold === false) {
|
|
173
|
+
problems.push(problem('xero_item_missing', `Line ${line.position}: Xero item ${item.code || line.itemCode} is not set to be sold. Turn on "I sell this item" in Xero, then sync.`, { itemCode: line.itemCode, short: `${item.code || line.itemCode} not sold in Xero` }));
|
|
174
|
+
} else {
|
|
175
|
+
out.ItemCode = item.code || line.itemCode;
|
|
176
|
+
if (settings.lineText === 'item' && item.name) out.Description = item.name;
|
|
177
|
+
}
|
|
167
178
|
} else if (line.accountCode) {
|
|
168
179
|
// A line with no item books to its own account, such as a surcharge.
|
|
169
180
|
out.AccountCode = line.accountCode;
|
|
@@ -324,7 +335,7 @@ async function previewOne(db, runtime, organisationId, invoiceId) {
|
|
|
324
335
|
const offerCreate = match.state === 'missing' && runtime.settings.createContacts && writesAllowed(runtime.settings);
|
|
325
336
|
if (match.state === 'missing') problems.push(missingContact(document.contact.name, { offerCreate }));
|
|
326
337
|
if (match.state === 'ambiguous') problems.push(twiceContact(document.contact.name));
|
|
327
|
-
const items = await
|
|
338
|
+
const items = await xeroItemsForCodes(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
328
339
|
const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
|
|
329
340
|
const { draft, problems: lineProblems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
|
|
330
341
|
problems.push(...lineProblems);
|
|
@@ -509,7 +520,7 @@ async function pushOne(db, runtime, client, organisationId, invoiceId, { pushedB
|
|
|
509
520
|
const found = match.state === 'missing' ? missingContact(document.contact.name, { offerCreate }) : twiceContact(document.contact.name);
|
|
510
521
|
throw xeroError(found.code, found.message, { status: 409, short: found.short });
|
|
511
522
|
}
|
|
512
|
-
const items = await
|
|
523
|
+
const items = await xeroItemsForCodes(db, organisationId, document.lines.map((l) => l.itemCode));
|
|
513
524
|
const sendMode = await sendModeFor(db, organisationId, invoice.contactId);
|
|
514
525
|
const { draft, problems } = buildDraft(document, { settings: runtime.settings, contact: match.contact, items, sendMode });
|
|
515
526
|
if (problems.length) throw xeroError(problems[0].code, problems.map((p) => p.message).join(' '), { status: 409, short: problems[0].short });
|
|
@@ -7,6 +7,7 @@ import { deleteXeroInvoice } from './delete-invoice.js';
|
|
|
7
7
|
import { createCustomer } from './create-contact.js';
|
|
8
8
|
import { checkDrafts, documentFacts, pushInvoice, pushInvoices } from './drafts.js';
|
|
9
9
|
import { readConnection } from './connections.js';
|
|
10
|
+
import { lastSuccessfulSyncAt, sellingCodes } from './catalogue.js';
|
|
10
11
|
import { createImportWorker } from './import.js';
|
|
11
12
|
import { readPayments, xeroLiveCheck } from './ledger.js';
|
|
12
13
|
import { findInvoices, listCustomers, listPriceItems, mirrorState } from './lookup.js';
|
|
@@ -47,6 +48,7 @@ export const XERO_SETUP = {
|
|
|
47
48
|
{ name: 'XERO_TENANT_NAME', label: 'Xero organisation name', help: 'Live mode connects only the organisation with this exact name.' },
|
|
48
49
|
{ name: 'XERO_ALLOW_LIVE_WRITES', label: 'Create draft invoices in live Xero', kind: 'onoff', help: 'Off: Flow only reads from Xero.' },
|
|
49
50
|
{ 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.' },
|
|
51
|
+
{ name: 'XERO_SYNC_NOW', label: 'Include in Sync now', kind: 'onoff', default: 'on', help: 'On: Sync now on the Integrations page also reads Xero\'s changed invoices and credit notes. Each uses a few of the day\'s Xero calls.' },
|
|
50
52
|
{ 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.' },
|
|
51
53
|
],
|
|
52
54
|
derivedKeys: ['XERO_TOKEN_KEY'],
|
|
@@ -159,6 +161,30 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
159
161
|
// Xero's latest changes into the copy just before Flow drafts by rule or
|
|
160
162
|
// sends (webhooks.js); no call when the copy is current.
|
|
161
163
|
readChanges: ({ organisationId }) => readChangesFirst(database, current(), organisationId),
|
|
164
|
+
// Sync now (server/core/sync-now.js): Xero's latest invoice and credit
|
|
165
|
+
// note changes into the copy now, for each connected organisation, even
|
|
166
|
+
// when the copy counts as current. Off while "Include in Sync now" is off,
|
|
167
|
+
// unless a person asked for Xero alone (`asked`, its own Sync button).
|
|
168
|
+
syncNow: async ({ asked = false } = {}) => {
|
|
169
|
+
const { settings } = current();
|
|
170
|
+
if (!settings.syncNow && !asked) return { state: 'off' };
|
|
171
|
+
const { rows } = await database.execute(sql`select organisation_id from xero_connections where mode = ${settings.mode}`);
|
|
172
|
+
if (!rows.length) return { state: 'unavailable', reason: 'Xero is not connected' };
|
|
173
|
+
let changed = 0;
|
|
174
|
+
for (const row of rows) {
|
|
175
|
+
const answer = await readChangesFirst(database, current(), row.organisation_id, { force: true });
|
|
176
|
+
if (answer.state === 'unavailable') return answer;
|
|
177
|
+
changed += answer.changed ?? 0;
|
|
178
|
+
}
|
|
179
|
+
return { state: 'read', changed };
|
|
180
|
+
},
|
|
181
|
+
syncNowStatus: async () => {
|
|
182
|
+
const { settings } = current();
|
|
183
|
+
const { rows: [row] } = await database.execute(sql`select max(c.last_read_at) as at from xero_mirror_cursors c
|
|
184
|
+
join xero_connections x on x.organisation_id = c.organisation_id and x.mode = ${settings.mode}
|
|
185
|
+
where c.resource = 'invoices'`);
|
|
186
|
+
return { included: settings.syncNow, running: false, lastAt: row?.at ? new Date(row.at).toISOString() : null, ok: null };
|
|
187
|
+
},
|
|
162
188
|
// Active Xero contacts, from Flow's copy (lookup.js).
|
|
163
189
|
listCustomers: ({ organisationId }) => listCustomers(database, organisationId),
|
|
164
190
|
// Create the Xero contact for one Flow contact, when that setting is on
|
|
@@ -166,6 +192,15 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
166
192
|
createCustomer: ({ organisationId, contactId, actor = null }) => createCustomer(database, current(), { organisationId, contactId, actor }),
|
|
167
193
|
// Sales invoices from the mirror, with the mirror's state (lookup.js).
|
|
168
194
|
findInvoices: ({ organisationId, ...query }) => findInvoices(database, organisationId, query),
|
|
195
|
+
// The Flow codes that send as a Xero item that sells (catalogue.js),
|
|
196
|
+
// for the invoice rule field "Every item is in accounting". Null while
|
|
197
|
+
// not connected or before the first item sync.
|
|
198
|
+
itemMatches: async ({ organisationId, codes = [] }) => {
|
|
199
|
+
const connection = await readConnection(database, organisationId);
|
|
200
|
+
if (!connection || connection.mode !== current().settings.mode) return null;
|
|
201
|
+
if (!(await lastSuccessfulSyncAt(database, organisationId))) return null;
|
|
202
|
+
return sellingCodes(database, organisationId, codes);
|
|
203
|
+
},
|
|
169
204
|
// Xero items, from Flow's copy, for the price list (lookup.js).
|
|
170
205
|
listPriceItems: ({ organisationId }) => listPriceItems(database, organisationId, { taxTypes: current().settings.taxTypes }),
|
|
171
206
|
},
|
|
@@ -107,7 +107,8 @@ function invoiceRow(r) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
// Sales invoices from the mirror, by any of: Xero ids, invoice numbers, the
|
|
110
|
-
// Flow invoices Flow sent, or one customer within a
|
|
110
|
+
// Flow invoices Flow sent or the Xero import linked, or one customer within a
|
|
111
|
+
// date range. Answers the
|
|
111
112
|
// mirror's state with them, and the latest change time it holds.
|
|
112
113
|
export async function findInvoices(db, organisationId, query = {}) {
|
|
113
114
|
const { invoiceIds = [], numbers = [], references = [], flowInvoiceIds = [], contactId = null, from = null, to = null } = query;
|
|
@@ -127,9 +128,15 @@ export async function findInvoices(db, organisationId, query = {}) {
|
|
|
127
128
|
select i.*, p.invoice_id as flow_invoice_id
|
|
128
129
|
from xero_invoices i
|
|
129
130
|
left join lateral (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
-- The Flow invoice this Xero invoice is: the one Flow sent it from,
|
|
132
|
+
-- else the one the Xero import linked or made from it.
|
|
133
|
+
select invoice_id from (
|
|
134
|
+
select invoice_id, 0 as pick, version from xero_invoice_pushes
|
|
135
|
+
where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
|
|
136
|
+
union all
|
|
137
|
+
select invoice_id, 1, 0 from xero_invoice_links
|
|
138
|
+
where organisation_id = i.organisation_id and xero_invoice_id = i.xero_invoice_id
|
|
139
|
+
) f order by pick, version desc limit 1
|
|
133
140
|
) p on true
|
|
134
141
|
where i.organisation_id = ${organisationId} and i.type = 'ACCREC'
|
|
135
142
|
and (${sql.join(where, sql` or `)})
|
|
@@ -163,6 +163,8 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
163
163
|
|
|
164
164
|
const invoiceNumber = (readEnv(env, 'XERO_INVOICE_NUMBER') || 'xero').toLowerCase();
|
|
165
165
|
if (!['xero', 'flow'].includes(invoiceNumber)) throw new Error('XERO_INVOICE_NUMBER must be "xero", "flow" or unset');
|
|
166
|
+
const lineText = (readEnv(env, 'XERO_LINE_TEXT') || 'flow').toLowerCase();
|
|
167
|
+
if (!['flow', 'item'].includes(lineText)) throw new Error('XERO_LINE_TEXT must be "flow", "item" or unset');
|
|
166
168
|
|
|
167
169
|
return {
|
|
168
170
|
mode,
|
|
@@ -184,6 +186,9 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
184
186
|
tracking: resolveTracking(env),
|
|
185
187
|
defaultDueDays: readPositiveInteger(env, 'XERO_DEFAULT_DUE_DAYS', DEFAULT_DUE_DAYS),
|
|
186
188
|
invoiceNumber,
|
|
189
|
+
// The text on each Xero line with an item: `flow` (default) the Flow
|
|
190
|
+
// line's own text; `item` the Xero item's name.
|
|
191
|
+
lineText,
|
|
187
192
|
replayFixtures: readEnv(env, 'XERO_REPLAY_FIXTURES') || null,
|
|
188
193
|
// Free text on Xero records (references, line descriptions) is the lab's
|
|
189
194
|
// own data, and the Import matches on it. On unless the lab turns it off.
|
|
@@ -191,6 +196,9 @@ export function resolveXeroSettings(env = process.env) {
|
|
|
191
196
|
// On unless the lab turns it off: a pending invoice whose client is not
|
|
192
197
|
// in Xero offers to create that client (docs/CONTACTS.md).
|
|
193
198
|
createContacts: readOnOff(env, 'XERO_CREATE_CONTACTS', true),
|
|
199
|
+
// On unless the lab turns it off: Sync now (server/core/sync-now.js) also
|
|
200
|
+
// reads Xero's changes. Each Sync now costs a few of the day's calls.
|
|
201
|
+
syncNow: readOnOff(env, 'XERO_SYNC_NOW', true),
|
|
194
202
|
mirrorPageSize: Math.min(readPositiveInteger(env, 'XERO_MIRROR_PAGE_SIZE', DEFAULT_MIRROR_PAGE_SIZE), MAX_MIRROR_PAGE_SIZE),
|
|
195
203
|
// The share of the day's calls the mirror leaves for drafts and people.
|
|
196
204
|
mirrorReserveFraction: readFraction(env, 'XERO_MIRROR_RESERVE_FRACTION', 0.3),
|
|
@@ -272,16 +272,17 @@ export async function dueDailyChecks(db, runtime, { now = Date.now() } = {}) {
|
|
|
272
272
|
// unavailable the read could not run (`reason`); act on nothing
|
|
273
273
|
export const READ_FIRST_MS = 10 * 60 * 1000;
|
|
274
274
|
|
|
275
|
-
|
|
275
|
+
// `force` (Sync now) reads even when the copy counts as current.
|
|
276
|
+
export async function readChangesFirst(db, runtime, organisationId, { now = Date.now, force = false } = {}) {
|
|
276
277
|
const { rows: [connection] } = await db.execute(sql`select tenant_id from xero_connections
|
|
277
278
|
where organisation_id = ${organisationId} and mode = ${runtime.settings.mode}`);
|
|
278
279
|
if (!connection) return { state: 'unavailable', reason: 'Xero is not connected' };
|
|
279
280
|
const { rows: [{ waiting }] } = await db.execute(sql`select count(*)::int as waiting from xero_webhook_events
|
|
280
281
|
where organisation_id = ${organisationId} and pending`);
|
|
281
|
-
if (!waiting && await webhooksActive(db, runtime, organisationId, { now: Number(now()) })) return { state: 'fresh' };
|
|
282
|
+
if (!force && !waiting && await webhooksActive(db, runtime, organisationId, { now: Number(now()) })) return { state: 'fresh' };
|
|
282
283
|
const { rows: [{ oldest }] } = await db.execute(sql`select min(last_read_at) as oldest from xero_mirror_cursors
|
|
283
284
|
where organisation_id = ${organisationId} and resource in ('invoices', 'credit_notes')`);
|
|
284
|
-
if (!waiting && oldest && Number(now()) - new Date(oldest).getTime() <= READ_FIRST_MS) return { state: 'fresh' };
|
|
285
|
+
if (!force && !waiting && oldest && Number(now()) - new Date(oldest).getTime() <= READ_FIRST_MS) return { state: 'fresh' };
|
|
285
286
|
try {
|
|
286
287
|
const inbox = await readInbox(db, runtime, organisationId, { now });
|
|
287
288
|
if (inbox.stopped) return { state: 'unavailable', reason: inbox.stopped === 'budget' ? 'the day\'s Xero calls are kept for people' : 'Xero calls are stopped' };
|
package/server/ui/app.js
CHANGED
|
@@ -640,6 +640,50 @@ function integrationLogo(integration, size = '') {
|
|
|
640
640
|
file ? h('img', { src: `/assets/ui/logos/${file}`, alt: '' }) : icon('plug'));
|
|
641
641
|
}
|
|
642
642
|
|
|
643
|
+
// Sync now (server/core/sync-now.js): reads each integration whose "Include
|
|
644
|
+
// in Sync now" setting is on, now. With `only`, it is that integration's own
|
|
645
|
+
// Sync on its page, and reads it whatever the setting says. Which ones, and
|
|
646
|
+
// when the last read started, are on hover. While a read goes on, the button
|
|
647
|
+
// says so and asks Flow again every 5 seconds (Flow's data only). Hidden while
|
|
648
|
+
// nothing it would read can sync.
|
|
649
|
+
function syncNowButton({ only = null } = {}) {
|
|
650
|
+
const word = only ? 'Sync' : 'Sync now';
|
|
651
|
+
const button = h('button', { class: 'fx-btn', type: 'button', hidden: true, disabled: true }, word);
|
|
652
|
+
const when = (at) => new Date(at).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' });
|
|
653
|
+
let timer = null;
|
|
654
|
+
const show = (all) => {
|
|
655
|
+
const list = only ? all.filter((i) => i.id === only) : all;
|
|
656
|
+
const included = only ? list : list.filter((i) => i.included);
|
|
657
|
+
const running = included.some((i) => i.running);
|
|
658
|
+
const last = included.map((i) => i.lastAt).filter(Boolean).sort().pop();
|
|
659
|
+
button.hidden = !list.length;
|
|
660
|
+
button.disabled = running || !included.length;
|
|
661
|
+
button.replaceChildren(...(running ? [h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }), 'Syncing'] : [word]));
|
|
662
|
+
button.title = included.length
|
|
663
|
+
? `Reads ${included.map((i) => i.name).join(' and ')}.${last ? ` Last read ${when(last)}.` : ''}`
|
|
664
|
+
: 'Turn on "Include in Sync now" in an integration\'s settings.';
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
if (running) timer = setTimeout(() => { if (button.isConnected) load(); }, 5000);
|
|
667
|
+
};
|
|
668
|
+
const load = () => flow('/api/integrations/sync-now')
|
|
669
|
+
.then(({ integrations }) => show(integrations))
|
|
670
|
+
.catch(() => { button.hidden = true; });
|
|
671
|
+
button.addEventListener('click', async () => {
|
|
672
|
+
button.disabled = true;
|
|
673
|
+
try {
|
|
674
|
+
const { results } = await flow('/api/integrations/sync-now', { method: 'POST', body: JSON.stringify(only ? { only } : {}) });
|
|
675
|
+
for (const r of results.filter((r) => r.state === 'unavailable')) toast(`${r.name}: ${r.reason}`, 'danger');
|
|
676
|
+
const reading = results.filter((r) => ['started', 'running', 'read'].includes(r.state));
|
|
677
|
+
if (reading.length) toast(`Reading ${reading.map((r) => r.name).join(' and ')}.`, 'success');
|
|
678
|
+
} catch (error) {
|
|
679
|
+
toast(error.message, 'danger');
|
|
680
|
+
}
|
|
681
|
+
load();
|
|
682
|
+
});
|
|
683
|
+
load();
|
|
684
|
+
return button;
|
|
685
|
+
}
|
|
686
|
+
|
|
643
687
|
async function renderIntegrations(main) {
|
|
644
688
|
const state = { tab: 'all', category: 'all', q: '' };
|
|
645
689
|
const tabs = h('div', { class: 'fx-seg', role: 'tablist' });
|
|
@@ -649,7 +693,7 @@ async function renderIntegrations(main) {
|
|
|
649
693
|
const notices = h('div', { class: 'fx-fields' });
|
|
650
694
|
const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
|
|
651
695
|
main.append(
|
|
652
|
-
pageHeader('Integrations'),
|
|
696
|
+
pageHeader('Integrations', { actions: [syncNowButton()] }),
|
|
653
697
|
alertStrip(ui(), 'integrations').element,
|
|
654
698
|
h('div', { class: 'fx-int-bar' }, tabs, h('div', { class: 'fx-row' }, category, search)),
|
|
655
699
|
notices, content);
|
|
@@ -848,6 +892,18 @@ async function renderIntegration(main, id) {
|
|
|
848
892
|
}
|
|
849
893
|
};
|
|
850
894
|
|
|
895
|
+
// A disconnect stops the integration, so it asks first: one stray click
|
|
896
|
+
// must not stop a lab's sync.
|
|
897
|
+
const disconnect = async () => {
|
|
898
|
+
const ok = await dialog({
|
|
899
|
+
title: `Disconnect ${current.name}?`,
|
|
900
|
+
body: h('p', {}, `Flow stops working with ${current.name} until someone connects it again. Its settings and Flow's records stay.`),
|
|
901
|
+
confirm: 'Disconnect',
|
|
902
|
+
tone: 'danger',
|
|
903
|
+
});
|
|
904
|
+
if (ok) await save(false);
|
|
905
|
+
};
|
|
906
|
+
|
|
851
907
|
const on = current.enabled;
|
|
852
908
|
const guide = current.guide;
|
|
853
909
|
const hostOwned = current.enabledBy === 'host';
|
|
@@ -855,10 +911,13 @@ async function renderIntegration(main, id) {
|
|
|
855
911
|
const modeLabel = mode?.choices?.find((c) => c.value === mode.value)?.label ?? mode?.value;
|
|
856
912
|
const busy = progress && progress.steps.some((step) => step.state === 'running');
|
|
857
913
|
|
|
858
|
-
const primary = !canChange || hostOwned ? null
|
|
859
|
-
? h('button', { class: 'fx-btn', type: 'button', onclick: () => save(false) }, 'Disconnect')
|
|
914
|
+
const primary = !canChange || hostOwned || on ? null
|
|
860
915
|
: h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled: busy, onclick: connect },
|
|
861
916
|
busy ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : null, busy ? 'Connecting' : 'Connect');
|
|
917
|
+
// Disconnect sits at the end of the page, away from Sync.
|
|
918
|
+
const disconnectRow = !canChange || hostOwned || !on ? null
|
|
919
|
+
: h('div', { class: 'fx-int-section' }, h('div', { class: 'fx-row fx-row--end' },
|
|
920
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button', onclick: disconnect }, 'Disconnect')));
|
|
862
921
|
|
|
863
922
|
host.replaceChildren(
|
|
864
923
|
crumbs(['/integrations', 'Integrations'], current.name),
|
|
@@ -869,6 +928,7 @@ async function renderIntegration(main, id) {
|
|
|
869
928
|
h('h1', { class: 'fx-title' }, current.name),
|
|
870
929
|
h('div', { class: 'fx-int-hero__actions' },
|
|
871
930
|
on ? statusPill(current) : null,
|
|
931
|
+
on && current.state === 'ready' ? syncNowButton({ only: current.id }) : null,
|
|
872
932
|
primary,
|
|
873
933
|
progress ? connectProgress(progress) : null)),
|
|
874
934
|
alerts.element,
|
|
@@ -893,6 +953,7 @@ async function renderIntegration(main, id) {
|
|
|
893
953
|
h('summary', {}, 'Advanced'),
|
|
894
954
|
h('div', { class: 'fx-settings' }, advanced)) : null,
|
|
895
955
|
canChange && on && inputs.size ? h('div', { class: 'fx-row fx-row--end' }, h('button', { class: 'fx-btn', type: 'button', onclick: () => save(undefined) }, 'Save changes')) : null) : null,
|
|
956
|
+
disconnectRow,
|
|
896
957
|
].filter(Boolean)),
|
|
897
958
|
h('aside', { class: 'fx-int-page__meta' },
|
|
898
959
|
h('dl', { class: 'fx-meta' }, ...[
|
package/server/ui/flow.css
CHANGED
|
@@ -1888,7 +1888,8 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1888
1888
|
content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px;
|
|
1889
1889
|
border-radius: 50%; background: var(--t-paper); transition: transform 0.15s;
|
|
1890
1890
|
}
|
|
1891
|
-
|
|
1891
|
+
/* background-color only: the shorthand would reset background-clip and fill the touch border. */
|
|
1892
|
+
.fx-switch[aria-checked="true"] { background-color: var(--t-primary); }
|
|
1892
1893
|
.fx-switch[aria-checked="true"]::after { transform: translateX(18px); }
|
|
1893
1894
|
.fx-switch:focus-visible { outline: none; box-shadow: var(--fx-focus); }
|
|
1894
1895
|
.fx-switch:disabled { cursor: default; opacity: 0.6; }
|
package/server/ui/kit.js
CHANGED
|
@@ -244,7 +244,7 @@ export function dialog({ title, body, confirm, tone = 'primary', choices = null
|
|
|
244
244
|
h('h2', { class: 'fx-dialog__title' }, title),
|
|
245
245
|
choiceList ?? body,
|
|
246
246
|
actions);
|
|
247
|
-
const element = applyPhoneRule(h('dialog', { class: 'fx-dialog' }, form), 'dialog');
|
|
247
|
+
const element = applyPhoneRule(h('dialog', { class: 'fx-dialog', 'aria-label': title }, form), 'dialog');
|
|
248
248
|
element.addEventListener('close', () => {
|
|
249
249
|
const value = element.returnValue;
|
|
250
250
|
element.remove();
|
|
@@ -258,7 +258,8 @@ export function dialog({ title, body, confirm, tone = 'primary', choices = null
|
|
|
258
258
|
if (choices) element.addEventListener('click', (event) => { if (event.target === element) element.close(); });
|
|
259
259
|
document.body.append(element);
|
|
260
260
|
element.showModal();
|
|
261
|
-
|
|
261
|
+
// A danger step starts on Cancel, so Enter or a second tap does not confirm it.
|
|
262
|
+
element.querySelector(choices ? '.fx-sheet__choice' : `input, select, button[value="${tone === 'danger' ? 'cancel' : 'ok'}"]`)?.focus();
|
|
262
263
|
});
|
|
263
264
|
}
|
|
264
265
|
|