@arcforgelabs/flow 2026.11.4 → 2026.11.6
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/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 +18 -3
- package/server/core/pipeline.js +5 -2
- package/server/core/price-list.js +41 -12
- package/server/core/sending.js +3 -2
- 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 +1 -0
- package/server/integrations/seazona/feed.js +1 -0
- package/server/integrations/seazona/history/index.js +5 -2
- 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/schema.js +2 -0
- package/server/integrations/seazona/store.js +14 -0
- package/server/integrations/seazona/sweep.js +3 -0
- package/server/integrations/sentry/index.js +9 -4
- package/server/integrations/xero/catalogue.js +40 -0
- package/server/integrations/xero/drafts.js +16 -5
- package/server/integrations/xero/index.js +10 -0
- package/server/integrations/xero/lookup.js +11 -4
- package/server/integrations/xero/settings.js +5 -0
- package/server/ui/app.js +2 -2
- package/server/ui/flow.css +37 -62
- package/server/ui/home.js +38 -47
- package/server/ui/invoicing.js +6 -2
- package/server/ui/job-change.js +15 -9
- package/server/ui/kit.js +21 -0
- package/server/ui/mobile.js +18 -0
- package/server/ui/monitoring.js +63 -3
- package/server/ui/price-list.js +5 -2
- package/server/ui/problem-prompt.js +79 -0
- package/server/ui/routes.js +1 -1
- package/skills/flow/SKILL.md +39 -1
|
@@ -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.
|
|
@@ -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';
|
|
@@ -216,6 +217,8 @@ async function sweepOrderDay(ctx, day, progress, helpers, found) {
|
|
|
216
217
|
}
|
|
217
218
|
}
|
|
218
219
|
await touchSynced(db, 'orders', unchanged, { at });
|
|
220
|
+
// The remake flag is on the list row: kept with no detail read.
|
|
221
|
+
await syncRemakeFlags(db, new Map(unchanged.map((id) => [id, listed.get(id)?.isRemake === true])), { at });
|
|
219
222
|
|
|
220
223
|
// Held for this day, left out of the list, and not seen this cycle.
|
|
221
224
|
for (const row of held) {
|
|
@@ -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,
|
|
@@ -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';
|
|
@@ -166,6 +167,15 @@ export function createXeroAdapter({ runtime: given = null } = {}) {
|
|
|
166
167
|
createCustomer: ({ organisationId, contactId, actor = null }) => createCustomer(database, current(), { organisationId, contactId, actor }),
|
|
167
168
|
// Sales invoices from the mirror, with the mirror's state (lookup.js).
|
|
168
169
|
findInvoices: ({ organisationId, ...query }) => findInvoices(database, organisationId, query),
|
|
170
|
+
// The Flow codes that send as a Xero item that sells (catalogue.js),
|
|
171
|
+
// for the invoice rule field "Every item is in accounting". Null while
|
|
172
|
+
// not connected or before the first item sync.
|
|
173
|
+
itemMatches: async ({ organisationId, codes = [] }) => {
|
|
174
|
+
const connection = await readConnection(database, organisationId);
|
|
175
|
+
if (!connection || connection.mode !== current().settings.mode) return null;
|
|
176
|
+
if (!(await lastSuccessfulSyncAt(database, organisationId))) return null;
|
|
177
|
+
return sellingCodes(database, organisationId, codes);
|
|
178
|
+
},
|
|
169
179
|
// Xero items, from Flow's copy, for the price list (lookup.js).
|
|
170
180
|
listPriceItems: ({ organisationId }) => listPriceItems(database, organisationId, { taxTypes: current().settings.taxTypes }),
|
|
171
181
|
},
|
|
@@ -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.
|
package/server/ui/app.js
CHANGED
|
@@ -28,7 +28,7 @@ import { isSearchKey, openSearch } from '/assets/ui/search.js';
|
|
|
28
28
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
29
29
|
import { initialsOf, personLabel } from '/assets/ui/person.js';
|
|
30
30
|
import { startTips } from '/assets/ui/tip.js';
|
|
31
|
-
import { cardRows, crumbs, dialog, edgeFade, fieldError, h, holdButton, iconPicker, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
31
|
+
import { cardRows, copyButton, crumbs, dialog, edgeFade, fieldError, h, holdButton, iconPicker, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
32
32
|
import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
|
|
33
33
|
|
|
34
34
|
const $ = (id) => document.getElementById(id);
|
|
@@ -339,7 +339,7 @@ function navigate(event) {
|
|
|
339
339
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
340
340
|
// takes a path, or a link's click event.
|
|
341
341
|
const ui = () => ({
|
|
342
|
-
h, flow, toast, notice, cardRows, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
|
|
342
|
+
h, flow, toast, notice, cardRows, copyButton, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, iconPicker, me, holdButton, tileCard, tileSection, crumbs,
|
|
343
343
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
344
344
|
});
|
|
345
345
|
|
package/server/ui/flow.css
CHANGED
|
@@ -237,6 +237,8 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
237
237
|
.fx-change__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; }
|
|
238
238
|
.fx-change__acts { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
239
239
|
.fx-change__note { font-size: 13px; color: var(--t-dim); }
|
|
240
|
+
.fx-copy__text { width: 100%; min-height: 200px; padding: 8px 10px; font-family: ui-monospace, monospace; font-size: 12px; resize: vertical; }
|
|
241
|
+
@media (max-width: 1023px) { .fx-copy { width: 44px; min-height: 44px; } }
|
|
240
242
|
.fx-change__recheck { width: 30px; }
|
|
241
243
|
.fx-change__recheck .fx-lucide { width: 14px; height: 14px; }
|
|
242
244
|
.fx-change__recheck.is-busy .fx-lucide { animation: fx-spin 1s linear infinite; }
|
|
@@ -359,71 +361,44 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
359
361
|
background: #fff; box-shadow: inset 0 0 0 1px var(--fx-line);
|
|
360
362
|
}
|
|
361
363
|
.fx-logo--icon { background: var(--t-accent-bg); color: var(--t-accent); box-shadow: none; }
|
|
362
|
-
/* Home:
|
|
363
|
-
|
|
364
|
-
.
|
|
365
|
-
.fx-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
background:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
.fx-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
.fx-
|
|
380
|
-
.fx-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
.fx-
|
|
385
|
-
.fx-
|
|
386
|
-
display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px;
|
|
387
|
-
color: var(--t-strong); font: 700 72px/1 var(--t-body); font-variant-numeric: tabular-nums; letter-spacing: -0.02em;
|
|
388
|
-
}
|
|
389
|
-
.fx-home__count span { font: 600 20px/1.2 var(--t-body); letter-spacing: 0; color: var(--t-text); }
|
|
390
|
-
.fx-home__send[data-done] .fx-home__count span { font-size: 22px; color: var(--t-strong); }
|
|
391
|
-
.fx-home__sum { color: var(--t-dim); font-size: 16px; font-variant-numeric: tabular-nums; }
|
|
392
|
-
.fx-home__btn { min-height: 56px; min-width: 200px; margin-top: 14px; padding: 0 32px; font-size: 17px; border-radius: 999px; }
|
|
393
|
-
.fx-home__btn .fx-lucide { width: 20px; height: 20px; }
|
|
394
|
-
.fx-home__note { color: var(--t-dim); font-size: 13.5px; }
|
|
395
|
-
|
|
396
|
-
/* Needs you: at most three rows beside Send; each opens its page. */
|
|
397
|
-
.fx-home__needs { display: grid; align-content: start; gap: 10px; }
|
|
398
|
-
.fx-home__needs h2 { margin: 0; }
|
|
399
|
-
.fx-home__needs ul { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
|
400
|
-
.fx-home__needs a {
|
|
401
|
-
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px;
|
|
402
|
-
min-height: 64px; padding: 10px 16px; border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg);
|
|
403
|
-
background: var(--t-paper); color: var(--t-text); text-decoration: none;
|
|
404
|
-
transition: border-color 140ms var(--fx-ease), transform 140ms var(--fx-ease);
|
|
405
|
-
}
|
|
406
|
-
.fx-home__needs a:hover, .fx-home__needs a:focus-visible { border-color: var(--t-control); transform: translateY(-1px); }
|
|
407
|
-
.fx-home__dot { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; background: var(--fx-hover); color: var(--t-dim); }
|
|
408
|
-
.fx-home__dot .fx-lucide { width: 18px; height: 18px; }
|
|
409
|
-
.fx-home__needs [data-tone="danger"] .fx-home__dot { background: var(--t-danger-bg); color: var(--t-danger); }
|
|
410
|
-
.fx-home__needs [data-tone="warn"] .fx-home__dot { background: var(--t-warn-bg); color: var(--t-warn); }
|
|
411
|
-
.fx-home__needs [data-tone="info"] .fx-home__dot { background: var(--t-accent-bg); color: var(--t-accent); }
|
|
412
|
-
.fx-home__needs [data-tone="clear"] .fx-home__dot { background: var(--t-success-bg); color: var(--t-success); }
|
|
413
|
-
.fx-home__what { display: grid; font-weight: 600; color: var(--t-strong); }
|
|
414
|
-
.fx-home__what small { color: var(--t-dim); font-weight: 400; font-size: 13px; font-variant-numeric: tabular-nums; }
|
|
415
|
-
.fx-home__needs [data-tone="clear"] .fx-home__what { color: var(--t-dim); font-weight: 500; }
|
|
416
|
-
.fx-home__n { color: var(--t-strong); font: 700 22px/1 var(--t-body); font-variant-numeric: tabular-nums; }
|
|
417
|
-
.fx-home__needs [data-tone="clear"] .fx-home__n { color: var(--t-dim); font-weight: 500; }
|
|
364
|
+
/* Home: a row of stat cards (label, number, total). A card with work has a
|
|
365
|
+
thin edge in its tone; a card at 0 is plain. The link covers the card;
|
|
366
|
+
Send sits above it. Four across on a desk, two on a tablet, rows on a phone. */
|
|
367
|
+
.fx-home { display: grid; gap: 16px; max-width: 1040px; }
|
|
368
|
+
.fx-stats { display: grid; gap: 12px; grid-template-columns: repeat(4, minmax(0, 1fr)); max-width: 1040px; }
|
|
369
|
+
.fx-stat {
|
|
370
|
+
position: relative; display: grid; align-content: start; gap: 12px; padding: 18px 20px;
|
|
371
|
+
border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); background: var(--t-paper);
|
|
372
|
+
box-shadow: inset 3px 0 0 var(--fx-stat-edge, transparent); transition: border-color .15s;
|
|
373
|
+
}
|
|
374
|
+
.fx-stat:hover, .fx-stat:focus-within { border-color: var(--t-control); }
|
|
375
|
+
.fx-stat[data-tone="accent"] { --fx-stat-edge: var(--t-accent); }
|
|
376
|
+
.fx-stat[data-tone="danger"] { --fx-stat-edge: var(--t-danger); }
|
|
377
|
+
.fx-stat[data-tone="warn"] { --fx-stat-edge: var(--t-warn); }
|
|
378
|
+
.fx-stat[data-tone="info"] { --fx-stat-edge: var(--t-accent); }
|
|
379
|
+
.fx-stat__link { display: grid; gap: 6px; color: inherit; text-decoration: none; }
|
|
380
|
+
.fx-stat__link::after { content: ''; position: absolute; inset: 0; border-radius: inherit; }
|
|
381
|
+
.fx-stat__link:focus-visible { outline: none; }
|
|
382
|
+
.fx-stat:has(.fx-stat__link:focus-visible) { outline: 2px solid var(--t-accent); outline-offset: 2px; }
|
|
383
|
+
.fx-stat__n { color: var(--t-strong); font: 600 34px/1.1 var(--t-body); font-variant-numeric: tabular-nums; }
|
|
384
|
+
.fx-stat[data-tone="none"] .fx-stat__n { color: var(--t-dim); font-weight: 500; }
|
|
385
|
+
.fx-stat__sum { min-height: 1.3em; color: var(--t-dim); font-size: 14px; font-variant-numeric: tabular-nums; }
|
|
386
|
+
.fx-stat__act { position: relative; z-index: 1; display: grid; gap: 6px; justify-items: start; }
|
|
387
|
+
.fx-stat__note { color: var(--t-dim); font-size: 13px; }
|
|
418
388
|
|
|
419
|
-
@media (
|
|
420
|
-
.fx-
|
|
421
|
-
.fx-
|
|
389
|
+
@media (max-width: 1023px) {
|
|
390
|
+
.fx-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
391
|
+
.fx-stat__act .fx-btn { min-height: 44px; }
|
|
422
392
|
}
|
|
423
393
|
@media (max-width: 639px) {
|
|
424
|
-
.fx-
|
|
425
|
-
.fx-
|
|
426
|
-
.fx-
|
|
394
|
+
.fx-stats { grid-template-columns: 1fr; gap: 8px; }
|
|
395
|
+
.fx-stat { grid-template-columns: 1fr auto; align-items: center; padding: 12px 16px; }
|
|
396
|
+
.fx-stat__link { grid-template-columns: 1fr auto; grid-column: 1 / -1; align-items: center; min-height: 44px; }
|
|
397
|
+
.fx-stat__link .fx-label { grid-row: 1; }
|
|
398
|
+
.fx-stat__sum { grid-row: 2; grid-column: 1; min-height: 0; }
|
|
399
|
+
.fx-stat__sum:empty { display: none; }
|
|
400
|
+
.fx-stat__n { grid-row: 1 / span 2; grid-column: 2; font-size: 26px; }
|
|
401
|
+
.fx-stat__act { grid-column: 1 / -1; justify-items: stretch; }
|
|
427
402
|
}
|
|
428
403
|
|
|
429
404
|
.fx-tiles { display: grid; gap: 12px; }
|
package/server/ui/home.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Home: the page a person lands on
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Home: the page a person lands on, a row of stat cards like an accounting
|
|
2
|
+
// dashboard. Ready (with Send), Pending, To match and Forms each show their
|
|
3
|
+
// count, and a total when it has one. A card with work has a thin edge in its
|
|
4
|
+
// tone; a card at 0 is plain. No card says "done": the counts say it. Each card
|
|
5
|
+
// opens its page. Send sends every ready invoice with the same step as the
|
|
6
|
+
// Invoices page (POST /invoices/send-ready). No calls of its own to other
|
|
7
7
|
// systems: every number is its page's own.
|
|
8
8
|
import { formatCents, localDate } from '/assets/ui/invoicing.js';
|
|
9
9
|
import { sendModeNote } from '/assets/ui/invoice-notes.js';
|
|
@@ -11,17 +11,10 @@ import { lucide } from '/assets/ui/custom-fields.js';
|
|
|
11
11
|
|
|
12
12
|
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
13
13
|
|
|
14
|
-
function greeting(person, now = new Date()) {
|
|
15
|
-
const hour = now.getHours();
|
|
16
|
-
const part = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
|
17
|
-
const first = String(person?.name ?? '').trim().split(/\s+/)[0];
|
|
18
|
-
return first ? `${part}, ${first}` : part;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
14
|
export async function renderHome(main, ui) {
|
|
22
|
-
const { h, flow, toast, notice, pageHeader, dialog, go
|
|
15
|
+
const { h, flow, toast, notice, pageHeader, dialog, go } = ui;
|
|
23
16
|
const host = h('div', { class: 'fx-home' });
|
|
24
|
-
main.append(pageHeader(
|
|
17
|
+
main.append(pageHeader('Home'), host);
|
|
25
18
|
const tabQuery = (tab) => `/api/invoicing/invoices?${new URLSearchParams({ status: tab, limit: '1' })}`;
|
|
26
19
|
|
|
27
20
|
async function load() {
|
|
@@ -45,45 +38,43 @@ export async function renderHome(main, ui) {
|
|
|
45
38
|
}
|
|
46
39
|
if (!host.isConnected) return;
|
|
47
40
|
draw(context, body, [
|
|
48
|
-
{ href: '/invoices?tab=pending',
|
|
49
|
-
|
|
50
|
-
forms && {
|
|
41
|
+
{ key: 'pending', href: '/invoices?tab=pending', label: 'Pending', tone: 'danger', count: pending.total, sum: pending.totalCents },
|
|
42
|
+
{ key: 'match', href: '/reconcile', label: 'Unmatched', tone: 'warn', count: toMatch ? (toMatch.jobs?.length ?? 0) + (toMatch.invoices?.length ?? 0) : null },
|
|
43
|
+
forms && { key: 'forms', href: '/captures', label: 'Forms', tone: 'info', count: forms.captures.filter((c) => c.state === 'ready').length },
|
|
51
44
|
].filter(Boolean));
|
|
52
45
|
}
|
|
53
46
|
|
|
47
|
+
// One stat card. The link covers the card; Send sits above it, so the card
|
|
48
|
+
// has no button inside a link. `count: null`: the page could not be read.
|
|
49
|
+
function card({ key, href, label, tone, count, sum = null }, extra = null) {
|
|
50
|
+
const unknown = count == null;
|
|
51
|
+
return h('article', { class: 'fx-stat', 'data-tone': count ? tone : 'none', 'aria-labelledby': `home-${key}` },
|
|
52
|
+
h('a', {
|
|
53
|
+
class: 'fx-stat__link', href, onclick: open, id: `home-${key}`, title: unknown ? 'Could not load' : null,
|
|
54
|
+
'aria-label': [label, unknown ? 'could not load' : String(count), count && sum != null ? formatCents(sum) : null].filter(Boolean).join(', '),
|
|
55
|
+
},
|
|
56
|
+
h('span', { class: 'fx-label' }, label),
|
|
57
|
+
h('span', { class: 'fx-stat__n' }, unknown ? '–' : String(count)),
|
|
58
|
+
h('span', { class: 'fx-stat__sum' }, count && sum != null ? formatCents(sum) : '')),
|
|
59
|
+
extra);
|
|
60
|
+
}
|
|
61
|
+
|
|
54
62
|
function draw(context, body, items) {
|
|
55
63
|
const to = body.sendsTo;
|
|
56
|
-
const
|
|
57
|
-
const ready = counts.ready ?? 0;
|
|
64
|
+
const ready = body.counts?.ready ?? 0;
|
|
58
65
|
const blocked = to && body.sendMode && (!body.sendMode.connected || !body.sendMode.writesAllowed);
|
|
59
66
|
const canSend = context.canIssue && !blocked;
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
blocked ? h('p', { class: 'fx-home__note' }, sendModeNote(to, body.sendMode)) : null)
|
|
72
|
-
: h('section', { class: 'fx-home__send', 'data-done': '', 'aria-labelledby': 'home-ready' },
|
|
73
|
-
h('span', { class: 'fx-home__done', 'aria-hidden': 'true' }, lucide(h, 'check')),
|
|
74
|
-
h('p', { class: 'fx-home__count', id: 'home-ready' }, h('span', {}, 'All caught up')),
|
|
75
|
-
h('p', { class: 'fx-home__sum' }, 'Nothing waits to be sent.'));
|
|
76
|
-
|
|
77
|
-
const needs = h('section', { class: 'fx-home__needs', 'aria-labelledby': 'home-needs' },
|
|
78
|
-
h('h2', { class: 'fx-label', id: 'home-needs' }, 'Needs you'),
|
|
79
|
-
h('ul', {}, items.map((item) => h('li', {},
|
|
80
|
-
h('a', { href: item.href, onclick: open, 'data-tone': item.count ? item.tone : 'clear' },
|
|
81
|
-
h('span', { class: 'fx-home__dot', 'aria-hidden': 'true' }, lucide(h, item.count ? item.icon : 'check')),
|
|
82
|
-
h('span', { class: 'fx-home__what' }, item.label,
|
|
83
|
-
item.count && item.sum ? h('small', {}, formatCents(item.sum)) : null),
|
|
84
|
-
h('span', { class: 'fx-home__n' }, String(item.count)))))));
|
|
85
|
-
|
|
86
|
-
host.replaceChildren(hero, needs);
|
|
67
|
+
// Send shows only to people who can send, and only while it can work.
|
|
68
|
+
const sendButton = ready && canSend ? h('div', { class: 'fx-stat__act' }, h('button', {
|
|
69
|
+
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
70
|
+
onclick: (event) => send(to, ready, body.sendMode, event.currentTarget),
|
|
71
|
+
}, lucide(h, 'send'), h('span', {}, 'Send'))) : null;
|
|
72
|
+
host.replaceChildren(...[
|
|
73
|
+
// A broken accounting connection shows at any count, not only with Ready.
|
|
74
|
+
blocked ? notice(sendModeNote(to, body.sendMode), 'warn') : null,
|
|
75
|
+
h('section', { class: 'fx-stats', 'aria-label': 'Invoices and jobs' },
|
|
76
|
+
card({ key: 'ready', href: '/invoices?tab=ready', label: 'Ready', tone: 'accent', count: ready, sum: body.totalCents }, sendButton),
|
|
77
|
+
items.map((item) => card(item)))].filter(Boolean));
|
|
87
78
|
}
|
|
88
79
|
|
|
89
80
|
function open(event) {
|
package/server/ui/invoicing.js
CHANGED
|
@@ -18,6 +18,7 @@ import { fieldGroup, fieldIdOf, filterCount, filterJobs, INVOICE_COLUMNS, invoic
|
|
|
18
18
|
import { INVOICE_FILTER_FIELDS, INVOICE_FILTER_STATUSES, invoiceFilterKind, invoiceKindOf, invoiceValue, normaliseInvoicesView } from '/assets/ui/list-views.js';
|
|
19
19
|
import { listPager, pager, PAGE_SIZES } from '/assets/ui/pager.js';
|
|
20
20
|
import { savedViews } from '/assets/ui/saved-views.js';
|
|
21
|
+
import { problemPrompt } from '/assets/ui/problem-prompt.js';
|
|
21
22
|
import { xeroContactActions } from '/assets/ui/xero-contact.js';
|
|
22
23
|
import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
|
|
23
24
|
|
|
@@ -1288,6 +1289,9 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1288
1289
|
? notice([...new Set(otherProblems.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.', { short: shorts })
|
|
1289
1290
|
: null;
|
|
1290
1291
|
const organisationId = context.organisations[0]?.id;
|
|
1292
|
+
// "Copy prompt": the full detail for a connected agent, built at the click.
|
|
1293
|
+
const prompt = () => problemPrompt({ origin: location.origin, invoice, change: jobChange, organisationId });
|
|
1294
|
+
const copyPrompt = () => ui.copyButton({ label: 'Copy prompt', text: prompt, copied: 'Prompt copied. Paste it to your agent.' });
|
|
1291
1295
|
// Link or Create the Xero client, then the invoice is checked again.
|
|
1292
1296
|
const clientActions = offer && context.canIssue && invoice?.contact?.id && organisationId
|
|
1293
1297
|
? xeroContactActions(ui, {
|
|
@@ -1339,10 +1343,10 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1339
1343
|
const problems = [
|
|
1340
1344
|
holdReview,
|
|
1341
1345
|
// Accepting changes the draft for the business: owners and admins only.
|
|
1342
|
-
jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
|
|
1346
|
+
jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw, prompt }) : null,
|
|
1343
1347
|
problemList.some(deferredJobReview) ? notice('The job also changed after this invoice was made. Resolve the source amount problem first. Select Recheck, then review the remaining job changes.', 'danger') : null,
|
|
1344
1348
|
unavailable.length ? notice('The resolution view is unavailable. Ask an administrator to restore it, then reopen this invoice. It remains Pending.', 'danger', 'Cannot resolve.') : null,
|
|
1345
|
-
problemNotice ?
|
|
1349
|
+
problemNotice ? h('div', { class: 'fx-offer' }, problemNotice, ...clientActions, ...ruleActions, jobChange ? null : copyPrompt()) : null,
|
|
1346
1350
|
];
|
|
1347
1351
|
// A red field names the banner as its reason (for a screen reader).
|
|
1348
1352
|
const banner = problemNotice;
|