@arcforgelabs/flow 2026.10.41 → 2026.10.43
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 +15 -5
- package/server/core/invoice-rules.js +49 -8
- package/server/core/sending.js +33 -5
- package/server/integrations/sentry/index.js +9 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +74 -29
- package/server/ui/automations.js +9 -3
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +77 -37
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoice-rules.js +38 -13
- package/server/ui/invoicing-settings.js +69 -35
- package/server/ui/invoicing.js +17 -51
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/jobs.js +88 -32
- package/server/ui/monitoring.js +1 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- package/server/ui/routes.js +1 -1
- package/server/ui/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/xero-import.js +7 -10
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.43",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.43",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/api/invoicing.js
CHANGED
|
@@ -43,7 +43,8 @@ import {
|
|
|
43
43
|
// POST /invoices/mark-ready { invoiceIds }: a person checked these drafts; one with a problem stays
|
|
44
44
|
// POST /invoices/mark-not-ready { invoiceIds }: back to To check
|
|
45
45
|
// POST /invoices/send-ready owner or admin: issue every ready draft and queue it for the
|
|
46
|
-
// accounting system { issueDate, invoiceIds? (only these
|
|
46
|
+
// accounting system { issueDate, invoiceIds? (only these; one that fails
|
|
47
|
+
// a check now stays on Pending and counts as held) }
|
|
47
48
|
// POST /invoices/send-again owner or admin: queue failed sends again { invoiceIds? }
|
|
48
49
|
// GET /invoices/:id one invoice: lines, contact, job, issued versions, and where it sends
|
|
49
50
|
// GET /invoices/:id/pdf?version= the invoice as a PDF, drawn by Flow (server/core/invoice-pdf.js),
|
|
@@ -542,8 +543,10 @@ export function createInvoicingRouter({
|
|
|
542
543
|
// the job at its source or adopt the change (ledger.js), as Move to
|
|
543
544
|
// Ready and Send do.
|
|
544
545
|
const { problems } = await invoiceView(db, organisationId, await getInvoice(db, organisationId, invoiceId));
|
|
545
|
-
|
|
546
|
-
|
|
546
|
+
// A lab's pending rule (invoice-rules.js) is a gate too: fix its cause
|
|
547
|
+
// or turn the rule off.
|
|
548
|
+
const held = problems.find((p) => p.code === 'job_review' || p.code === 'rule');
|
|
549
|
+
if (held) throw new FlowError('not_ready', `${held.message.replace(/\.?$/, '.')} Nothing was approved.`);
|
|
547
550
|
const issued = await issueInvoice(db, organisationId, invoiceId, { issuedBy: `person:${req.person.id}`, issueDate });
|
|
548
551
|
log.info?.(`[invoicing] organisation ${organisationId}: person ${req.person.id} issued invoice ${issued.number} (version ${issued.issuedVersion})`);
|
|
549
552
|
res.json({ ok: true, invoice: await invoiceDetail(db, organisationId, invoiceId, accounting(), work()) });
|
|
@@ -773,7 +776,7 @@ export function createInvoicingRouter({
|
|
|
773
776
|
// with their tests, and the values Flow already holds for each kind of menu.
|
|
774
777
|
router.get('/rule-fields', ...signedIn, handle(log, async (req, res) => {
|
|
775
778
|
const organisationId = await organisationOf(req);
|
|
776
|
-
const [statuses, customers, items] = await Promise.all([
|
|
779
|
+
const [statuses, customers, items, practitioners] = await Promise.all([
|
|
777
780
|
workStatuses(db, organisationId),
|
|
778
781
|
db.select({ value: contacts.id, label: contacts.name }).from(contacts)
|
|
779
782
|
.where(and(eq(contacts.organisationId, organisationId), eq(contacts.kind, 'customer')))
|
|
@@ -781,13 +784,20 @@ export function createInvoicingRouter({
|
|
|
781
784
|
db.select({ value: priceItems.code, label: priceItems.name }).from(priceItems)
|
|
782
785
|
.where(and(eq(priceItems.organisationId, organisationId), isNull(priceItems.archivedAt)))
|
|
783
786
|
.orderBy(asc(priceItems.code)).limit(1000),
|
|
787
|
+
// The practitioners on the jobs, as the work system wrote them, most used first.
|
|
788
|
+
db.select({ value: billingObligations.practitioner, n: sql`count(*)::int` }).from(billingObligations)
|
|
789
|
+
.where(and(eq(billingObligations.organisationId, organisationId), sql`coalesce(trim(${billingObligations.practitioner}), '') <> ''`))
|
|
790
|
+
.groupBy(billingObligations.practitioner).orderBy(sql`count(*) desc`, asc(billingObligations.practitioner)).limit(500),
|
|
784
791
|
]);
|
|
785
792
|
res.json({
|
|
786
793
|
ok: true,
|
|
787
|
-
fields: Object.entries(FIELDS).map(([key, f]) => ({
|
|
794
|
+
fields: Object.entries(FIELDS).map(([key, f]) => ({
|
|
795
|
+
key, label: f.label, kind: f.kind, options: f.options ?? null, tests: TESTS[f.kind], draftOnly: Boolean(f.draftOnly),
|
|
796
|
+
})),
|
|
788
797
|
actions: ACTIONS,
|
|
789
798
|
options: {
|
|
790
799
|
statuses: statuses.map((s) => ({ value: s.value, label: s.label })),
|
|
800
|
+
practitioners: practitioners.map((p) => ({ value: p.value, label: `${p.value} (${p.n})` })),
|
|
791
801
|
customers,
|
|
792
802
|
items: [
|
|
793
803
|
...items.map((i) => ({ value: i.value, label: `${i.value} · ${i.label}` })),
|
|
@@ -9,7 +9,10 @@ import { FlowError, notFound } from './errors.js';
|
|
|
9
9
|
// When job_ready: the job is ready to invoice and has no invoice yet.
|
|
10
10
|
// If every condition matches: a field of the job, its customer or its
|
|
11
11
|
// lines, a test, and a value.
|
|
12
|
-
// Then wait: the job waits for a person; skip: rules never draft it
|
|
12
|
+
// Then wait: the job waits for a person; skip: rules never draft it;
|
|
13
|
+
// pending: Flow drafts it, and the draft is Pending with the rule's
|
|
14
|
+
// note, which says what to fix (for example a Reference with no
|
|
15
|
+
// real practitioner).
|
|
13
16
|
//
|
|
14
17
|
// The rules read only Flow's own records, so they work with or without an
|
|
15
18
|
// accounting integration. The earlier fixed settings (old job limit, required
|
|
@@ -19,6 +22,7 @@ const MAX_RULES = 100;
|
|
|
19
22
|
const MAX_CONDITIONS = 10;
|
|
20
23
|
const invalid = (message) => new FlowError('invalid', message);
|
|
21
24
|
|
|
25
|
+
const MAX_NOTE = 200;
|
|
22
26
|
const day = (date) => new Date(`${String(date).slice(0, 10)}T00:00:00Z`);
|
|
23
27
|
const norm = (value) => String(value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
24
28
|
// A line's key: its price item code, else the label line mapping gave it (a
|
|
@@ -40,7 +44,9 @@ export const FIELDS = Object.freeze({
|
|
|
40
44
|
job_age_days: { label: 'Job age (days)', kind: 'number', read: (j, { now }) => (j.jobDate ? Math.floor((now - day(j.jobDate)) / 86_400_000) : null) },
|
|
41
45
|
job_total: { label: 'Job total', kind: 'money', read: (j) => (j.totalCents ?? 0) / 100 },
|
|
42
46
|
job_status: { label: 'Job status', kind: 'choice', options: 'statuses', read: (j) => norm(j.sourceStatus) || null },
|
|
43
|
-
practitioner: { label: 'Practitioner', kind: 'text', read: (j, { customer }) => j.practitioner || customer.defaultPractitioner || null },
|
|
47
|
+
practitioner: { label: 'Practitioner', kind: 'text', options: 'practitioners', read: (j, { customer }) => j.practitioner || customer.defaultPractitioner || null },
|
|
48
|
+
// Only a draft has a Reference, so only a pending rule may read it.
|
|
49
|
+
reference: { label: 'Reference', kind: 'text', draftOnly: true, read: (j) => j.reference || null },
|
|
44
50
|
customer: { label: 'Customer', kind: 'choice', options: 'customers', read: (j) => j.contactId },
|
|
45
51
|
customer_billable: { label: 'Customer is billable', kind: 'boolean', read: (j, { customer }) => customer.billable !== false },
|
|
46
52
|
line_item: { label: 'Line item', kind: 'items', options: 'items', read: (j) => (j.lines ?? []).flatMap((l) => [norm(l.itemCode), norm(l.label)].filter(Boolean)) },
|
|
@@ -51,13 +57,15 @@ export const TESTS = Object.freeze({
|
|
|
51
57
|
number: ['more_than', 'at_most', 'is', 'is_not', 'is_empty'],
|
|
52
58
|
money: ['more_than', 'at_most', 'is', 'is_not'],
|
|
53
59
|
choice: ['is', 'is_not', 'is_one_of', 'is_empty'],
|
|
54
|
-
text: ['is', 'is_not', 'is_empty', 'is_not_empty'],
|
|
60
|
+
text: ['is', 'is_not', 'is_one_of', 'is_not_one_of', 'contains', 'does_not_contain', 'starts_with', 'does_not_start_with', 'has_number', 'one_word', 'is_empty', 'is_not_empty'],
|
|
55
61
|
boolean: ['is'],
|
|
56
62
|
items: ['has', 'has_not'],
|
|
57
63
|
share: ['is', 'is_not'],
|
|
58
64
|
});
|
|
59
65
|
|
|
60
|
-
export const ACTIONS = Object.freeze(['wait', 'skip']);
|
|
66
|
+
export const ACTIONS = Object.freeze(['wait', 'skip', 'pending']);
|
|
67
|
+
// Tests that take no value.
|
|
68
|
+
const NO_VALUE = new Set(['is_empty', 'is_not_empty', 'has_number', 'one_word']);
|
|
61
69
|
|
|
62
70
|
function readCondition(raw, index) {
|
|
63
71
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw invalid(`condition ${index + 1} must be an object`);
|
|
@@ -65,13 +73,13 @@ function readCondition(raw, index) {
|
|
|
65
73
|
if (!field) throw invalid(`condition ${index + 1}: unknown field ${raw.field}`);
|
|
66
74
|
if (!TESTS[field.kind].includes(raw.test)) throw invalid(`condition ${index + 1}: ${field.label} takes ${TESTS[field.kind].join(', ')}`);
|
|
67
75
|
const out = { field: raw.field, test: raw.test };
|
|
68
|
-
if (
|
|
76
|
+
if (NO_VALUE.has(raw.test)) return out;
|
|
69
77
|
const { value } = raw;
|
|
70
78
|
if (field.kind === 'boolean') {
|
|
71
79
|
if (typeof value !== 'boolean') throw invalid(`condition ${index + 1}: the value must be true or false`);
|
|
72
80
|
} else if (['number', 'money', 'share'].includes(field.kind)) {
|
|
73
81
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1e9) throw invalid(`condition ${index + 1}: the value must be a number`);
|
|
74
|
-
} else if (raw.test
|
|
82
|
+
} else if (['is_one_of', 'is_not_one_of'].includes(raw.test)) {
|
|
75
83
|
if (!Array.isArray(value) || !value.length || value.length > 50 || value.some((v) => typeof v !== 'string' || !v.trim() || v.length > 200)) {
|
|
76
84
|
throw invalid(`condition ${index + 1}: pick 1 to 50 values`);
|
|
77
85
|
}
|
|
@@ -98,7 +106,17 @@ export function readRule(raw) {
|
|
|
98
106
|
throw invalid(`the rule needs 1 to ${MAX_CONDITIONS} conditions`);
|
|
99
107
|
}
|
|
100
108
|
if (!ACTIONS.includes(raw.action?.kind)) throw invalid(`the action must be one of ${ACTIONS.join(', ')}`);
|
|
101
|
-
|
|
109
|
+
const conditions = raw.conditions.map(readCondition);
|
|
110
|
+
const action = { kind: raw.action.kind };
|
|
111
|
+
if (action.kind === 'pending') {
|
|
112
|
+
const note = typeof raw.action.note === 'string' ? raw.action.note.trim() : '';
|
|
113
|
+
if (!note || note.length > MAX_NOTE) throw invalid(`a pending rule needs a note of ${MAX_NOTE} characters at most: what a person must fix`);
|
|
114
|
+
action.note = note;
|
|
115
|
+
} else {
|
|
116
|
+
const early = conditions.find((c) => FIELDS[c.field].draftOnly);
|
|
117
|
+
if (early) throw invalid(`${FIELDS[early.field].label} is on the draft only: use Then "Pending, with a note"`);
|
|
118
|
+
}
|
|
119
|
+
return { name, enabled, conditions, action };
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
function matches(condition, job, context) {
|
|
@@ -107,6 +125,18 @@ function matches(condition, job, context) {
|
|
|
107
125
|
const { test, value } = condition;
|
|
108
126
|
if (test === 'is_empty') return have === null || have === '' || have === undefined;
|
|
109
127
|
if (test === 'is_not_empty') return !(have === null || have === '' || have === undefined);
|
|
128
|
+
if (field.kind === 'text' && !['is', 'is_not', 'is_one_of'].includes(test)) {
|
|
129
|
+
// An empty text matches no test that reads its words; is_empty finds it.
|
|
130
|
+
const left = norm(have);
|
|
131
|
+
if (!left) return false;
|
|
132
|
+
if (test === 'is_not_one_of') return !value.some((v) => norm(v) === left);
|
|
133
|
+
if (test === 'has_number') return /\d/.test(left);
|
|
134
|
+
if (test === 'one_word') return !left.includes(' ');
|
|
135
|
+
if (test === 'contains') return left.includes(norm(value));
|
|
136
|
+
if (test === 'does_not_contain') return !left.includes(norm(value));
|
|
137
|
+
if (test === 'starts_with') return left.startsWith(norm(value));
|
|
138
|
+
if (test === 'does_not_start_with') return !left.startsWith(norm(value));
|
|
139
|
+
}
|
|
110
140
|
switch (field.kind) {
|
|
111
141
|
case 'items': {
|
|
112
142
|
const found = have.includes(norm(value));
|
|
@@ -142,13 +172,24 @@ export function ruleReasons(rules, job, { customer = {}, now = new Date() } = {}
|
|
|
142
172
|
const context = { customer, now };
|
|
143
173
|
const reasons = [];
|
|
144
174
|
for (const rule of rules) {
|
|
145
|
-
if (!rule.enabled || rule.trigger !== 'job_ready') continue;
|
|
175
|
+
if (!rule.enabled || rule.trigger !== 'job_ready' || rule.action.kind === 'pending') continue;
|
|
146
176
|
if (!rule.conditions.every((c) => matches(c, job, context))) continue;
|
|
147
177
|
reasons.push(rule.action.kind === 'skip' ? `rule "${rule.name}": not drafted by rule` : `rule "${rule.name}": waits for a person`);
|
|
148
178
|
}
|
|
149
179
|
return reasons;
|
|
150
180
|
}
|
|
151
181
|
|
|
182
|
+
// The notes of the enabled pending rules that match one draft, as problems:
|
|
183
|
+
// they put the draft on Pending (sending.js). `job` is the draft's job with
|
|
184
|
+
// `reference`, the draft's Reference. A pending rule never stops a draft.
|
|
185
|
+
export function rulePending(rules, job, { customer = {}, now = new Date() } = {}) {
|
|
186
|
+
const context = { customer, now };
|
|
187
|
+
return rules
|
|
188
|
+
.filter((r) => r.enabled && r.trigger === 'job_ready' && r.action.kind === 'pending')
|
|
189
|
+
.filter((r) => r.conditions.every((c) => matches(c, job, context)))
|
|
190
|
+
.map((r) => ({ code: 'rule', message: r.action.note, short: r.name.slice(0, 40), rule: r.id ?? null }));
|
|
191
|
+
}
|
|
192
|
+
|
|
152
193
|
// The starter rules made from the earlier fixed settings.
|
|
153
194
|
export function starterRules(settings) {
|
|
154
195
|
const rules = [
|
package/server/core/sending.js
CHANGED
|
@@ -2,6 +2,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
|
|
3
3
|
import { billingObligations, invoiceLines, invoices } from '../db/schema.js';
|
|
4
4
|
import { FlowError } from './errors.js';
|
|
5
|
+
import { listRules, rulePending } from './invoice-rules.js';
|
|
6
|
+
import { customerBillingByContact, effectiveSettings, readInvoicingSettings } from './invoicing-rules.js';
|
|
5
7
|
import { issueInvoice } from './invoices.js';
|
|
6
8
|
import { CHANGED_REASON, diffJobLines, STATUS_BACK_REASON } from './ledger.js';
|
|
7
9
|
import { soleOrganisation } from './pipeline.js';
|
|
@@ -14,7 +16,8 @@ import { deadHoldSince, deadSince, enqueueJob, runNextJob } from './queue.js';
|
|
|
14
16
|
// a total above zero, and a price item (or, for a line such as a rush
|
|
15
17
|
// charge, an account) on every line. When an accounting
|
|
16
18
|
// system is connected, its own checks are added (for Xero: the customer
|
|
17
|
-
// matches one Xero contact, every item is in Xero)
|
|
19
|
+
// matches one Xero contact, every item is in Xero), and the lab's pending
|
|
20
|
+
// rules (invoice-rules.js) add their notes. A draft with a problem is
|
|
18
21
|
// Pending (the "problem" view). A draft without one is Ready.
|
|
19
22
|
//
|
|
20
23
|
// Send all ready issues every ready draft (each gets its number) and, when an
|
|
@@ -77,10 +80,25 @@ async function checkDrafts(db, organisationId, drafts, accounting) {
|
|
|
77
80
|
const found = draftProblems(draft, linesOf.get(draft.id) ?? []);
|
|
78
81
|
if (found.length) problems.set(draft.id, found);
|
|
79
82
|
}
|
|
83
|
+
const jobs = await db.select({ obligation: billingObligations, reference: invoices.reference }).from(billingObligations)
|
|
84
|
+
.innerJoin(invoices, eq(invoices.id, billingObligations.invoiceId))
|
|
85
|
+
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids)));
|
|
86
|
+
// The lab's pending rules (invoice-rules.js): a draft from a job that
|
|
87
|
+
// matches one waits on Pending with the rule's note, such as a Reference
|
|
88
|
+
// with no real practitioner.
|
|
89
|
+
const rules = (await listRules(db, organisationId)).filter((r) => r.enabled && r.action.kind === 'pending');
|
|
90
|
+
if (rules.length && jobs.length) {
|
|
91
|
+
const settings = await readInvoicingSettings(db, organisationId);
|
|
92
|
+
const customers = await customerBillingByContact(db, organisationId, jobs.map((j) => j.obligation.contactId));
|
|
93
|
+
for (const { obligation, reference } of jobs) {
|
|
94
|
+
const customer = effectiveSettings(settings, customers.get(obligation.contactId));
|
|
95
|
+
const found = rulePending(rules, { ...obligation, reference }, { customer });
|
|
96
|
+
if (found.length) problems.set(obligation.invoiceId, [...(problems.get(obligation.invoiceId) ?? []), ...found]);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
80
99
|
// A draft whose job the ledger sent to review (the job changed after the
|
|
81
100
|
// draft was made, or the work system deleted it) waits for a person.
|
|
82
|
-
const reviewed =
|
|
83
|
-
.where(and(eq(billingObligations.organisationId, organisationId), inArray(billingObligations.invoiceId, ids), eq(billingObligations.holdState, 'review')));
|
|
101
|
+
const reviewed = jobs.map((j) => j.obligation).filter((o) => o.holdState === 'review');
|
|
84
102
|
for (const obligation of reviewed) {
|
|
85
103
|
const { invoiceId, holdReason: reason } = obligation;
|
|
86
104
|
const found = problem('job_review', `The job needs a look before this invoice goes: ${reason ?? 'it was sent to review'}.`, reviewShort(obligation));
|
|
@@ -201,11 +219,21 @@ export async function sendAllReady(db, organisationId, { accounting = null, work
|
|
|
201
219
|
let views = await invoiceViews(db, organisationId, { accounting });
|
|
202
220
|
const accountingRead = views.ready.length ? await readAccountingFirst(db, organisationId, { accounting, now }) : { outcome: 'fresh' };
|
|
203
221
|
if (accountingRead.changed) views = await invoiceViews(db, organisationId, { accounting });
|
|
222
|
+
// Every check runs again at the press, as a gate: a ticked draft that
|
|
223
|
+
// fails one now (someone edited it, its job changed, a rule matches) is not
|
|
224
|
+
// sent and stays on Pending with its problem. The rest go.
|
|
204
225
|
let { ready } = views;
|
|
226
|
+
let held = 0;
|
|
205
227
|
if (invoiceIds) {
|
|
206
228
|
const wanted = new Set(invoiceIds);
|
|
207
229
|
ready = ready.filter((id) => wanted.has(id));
|
|
208
|
-
|
|
230
|
+
held = wanted.size - ready.length;
|
|
231
|
+
if (!ready.length) {
|
|
232
|
+
const reason = invoiceIds.map((id) => views.problems.get(id)?.[0]?.message).find(Boolean);
|
|
233
|
+
throw new FlowError('not_ready', reason
|
|
234
|
+
? `Not sent: ${reason.replace(/\.?$/, '.')} The invoice is on Pending.`
|
|
235
|
+
: 'Only a ready invoice can be sent. Mark it ready first.');
|
|
236
|
+
}
|
|
209
237
|
}
|
|
210
238
|
const { go, waiting } = accountingRead.outcome === 'waiting'
|
|
211
239
|
? { go: [], waiting: ready.slice(0, SEND_BATCH) }
|
|
@@ -229,7 +257,7 @@ export async function sendAllReady(db, organisationId, { accounting = null, work
|
|
|
229
257
|
}
|
|
230
258
|
issued += 1;
|
|
231
259
|
}
|
|
232
|
-
return { issued, queued: accounting ? issued : 0, remaining: Math.max(0, ready.length - SEND_BATCH), waiting: waiting.length };
|
|
260
|
+
return { issued, queued: accounting ? issued : 0, remaining: Math.max(0, ready.length - SEND_BATCH), waiting: waiting.length, held };
|
|
233
261
|
}
|
|
234
262
|
|
|
235
263
|
// Send again: issued invoices that failed go back on the queue. With no ids,
|
|
@@ -42,6 +42,7 @@ export const SENTRY_SETUP = {
|
|
|
42
42
|
fields: [
|
|
43
43
|
{ name: 'SENTRY_DSN', label: 'DSN', kind: 'url', help: 'From the Sentry project\'s Client Keys. Browsers see it, so it is not a secret.' },
|
|
44
44
|
{ name: 'SENTRY_ENVIRONMENT', label: 'Environment', help: 'How Sentry labels this installation, for example production. Empty: production, or workspace in a Replit workspace.' },
|
|
45
|
+
{ name: 'SENTRY_CLIENT', label: 'Client', help: 'The name your support desk files this installation\'s reports under. Sent as the client tag. Empty: no client tag.' },
|
|
45
46
|
{ name: 'SENTRY_FEEDBACK', label: 'Report a problem button', kind: 'onoff', help: 'On: everyone signed in can send a report, with a screenshot, to this Sentry project.' },
|
|
46
47
|
{ name: 'SENTRY_TRACES_SAMPLE_RATE', label: 'Traces', help: 'The share of clicks, page loads and requests timed, from 0 (off) to 1 (all). Empty: 0.2.' },
|
|
47
48
|
],
|
|
@@ -60,7 +61,12 @@ export function readSentrySettings(env) {
|
|
|
60
61
|
if (!/^\d*\.?\d+$/.test(rate || '0') || !(tracesSampleRate >= 0 && tracesSampleRate <= 1)) {
|
|
61
62
|
throw new Error('SENTRY_TRACES_SAMPLE_RATE must be a number from 0 to 1, such as 0.2.');
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
+
const client = String(env.SENTRY_CLIENT ?? '').trim();
|
|
65
|
+
if (client.length > 60 || /[\r\n]/.test(client)) throw new Error('SENTRY_CLIENT must be one short line.');
|
|
66
|
+
// Every event says which product and, when set, which client it is from,
|
|
67
|
+
// so one support desk can serve many installations.
|
|
68
|
+
const tags = client ? { product: 'flow', client } : { product: 'flow' };
|
|
69
|
+
return { dsn, environment, feedback, tracesSampleRate, tags };
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
// No trace for the host's health check: it is not a person waiting.
|
|
@@ -92,6 +98,7 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
92
98
|
dsn: settings.dsn,
|
|
93
99
|
environment: settings.environment,
|
|
94
100
|
release: release ?? undefined,
|
|
101
|
+
initialScope: { tags: settings.tags },
|
|
95
102
|
// Flow sends what it logs; no request bodies, cookies or IP addresses.
|
|
96
103
|
sendDefaultPii: false,
|
|
97
104
|
// A request the browser traced keeps the browser's choice, so a
|
|
@@ -132,6 +139,7 @@ export function createSentryAdapter({ sdk = SentryNode, env: hostEnv = process.e
|
|
|
132
139
|
environment: settings.environment,
|
|
133
140
|
release,
|
|
134
141
|
feedback: settings.feedback,
|
|
142
|
+
tags: settings.tags,
|
|
135
143
|
tracesSampleRate,
|
|
136
144
|
script: tracesSampleRate > 0 ? SENTRY_BROWSER_TRACING : SENTRY_BROWSER,
|
|
137
145
|
};
|
package/server/ui/app-page.js
CHANGED
|
@@ -51,7 +51,7 @@ const BODY = `<div class="fx-app">
|
|
|
51
51
|
<div id="toasts" class="fx-toasts" aria-live="polite"></div>
|
|
52
52
|
</div>`;
|
|
53
53
|
|
|
54
|
-
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/price-list', '/custom-fields'];
|
|
54
|
+
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/reconcile', '/reconcile/:id', '/xero-import', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings', '/invoicing-settings/:id', '/automations', '/price-list', '/custom-fields'];
|
|
55
55
|
|
|
56
56
|
export function appPage({ defaultFamily }) {
|
|
57
57
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -10,7 +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 { automationSection } from '/assets/ui/automations.js';
|
|
13
|
+
import { automationSection, renderAutomations } from '/assets/ui/automations.js';
|
|
14
14
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
15
15
|
import { renderPriceList } from '/assets/ui/price-list.js';
|
|
16
16
|
import { renderPriceReconcile } from '/assets/ui/price-reconcile.js';
|
|
@@ -21,6 +21,7 @@ import { renderXeroImport } from '/assets/ui/xero-import.js';
|
|
|
21
21
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
22
22
|
import { openSearch } from '/assets/ui/search.js';
|
|
23
23
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
24
|
+
import { named, startTips } from '/assets/ui/tip.js';
|
|
24
25
|
|
|
25
26
|
const $ = (id) => document.getElementById(id);
|
|
26
27
|
|
|
@@ -164,14 +165,28 @@ function holdButton(button, ms) {
|
|
|
164
165
|
|
|
165
166
|
// ── Shared pieces ──────────────────────────────────────────────────────────
|
|
166
167
|
|
|
167
|
-
|
|
168
|
+
// The page's name and nothing under it: the page shows what it is for.
|
|
169
|
+
// `count` is a live number beside the name (2 to check); `help` is a docs
|
|
170
|
+
// link, an icon with its name as a tip.
|
|
171
|
+
function pageHeader(title, { count = null, help = null, actions = [] } = {}) {
|
|
168
172
|
return h('header', { class: 'fx-ph' },
|
|
169
173
|
h('div', { class: 'fx-ph__text' },
|
|
170
174
|
h('h1', { class: 'fx-title' }, title),
|
|
171
|
-
|
|
175
|
+
count != null ? h('span', { class: 'fx-ph__count' }, count) : null,
|
|
176
|
+
help ? helpLink(help) : null),
|
|
172
177
|
actions.length ? h('div', { class: 'fx-ph__actions' }, actions) : null);
|
|
173
178
|
}
|
|
174
179
|
|
|
180
|
+
// A docs link as a small "?" icon whose tip names it (How sending works).
|
|
181
|
+
function helpLink({ href, label }) {
|
|
182
|
+
return named(h('a', { class: 'fx-help', href, target: '_blank', rel: 'noopener' }, lucide(h, 'circle-help')), label);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A settings section's heading, with its docs link beside it.
|
|
186
|
+
function sectionTitle(text, help = null) {
|
|
187
|
+
return h('h2', { class: 'fx-set__title' }, text, help ? helpLink(help) : null);
|
|
188
|
+
}
|
|
189
|
+
|
|
175
190
|
function notice(text, tone = 'info', title = null) {
|
|
176
191
|
return h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
|
|
177
192
|
title ? h('strong', {}, title) : null,
|
|
@@ -235,7 +250,8 @@ const VIEWS = {
|
|
|
235
250
|
jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
|
|
236
251
|
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
237
252
|
reconcile: { label: 'Reconcile', icon: 'reconcile', group: 'Work', render: (main, id) => renderReconcile(main, ui(), id) },
|
|
238
|
-
'invoicing-settings': { label: '
|
|
253
|
+
'invoicing-settings': { label: 'Rules', icon: 'rules', group: 'Settings', render: (main, id) => renderInvoicingSettings(main, ui(), id) },
|
|
254
|
+
automations: { label: 'Automations', icon: 'bolt', group: 'Settings', render: (main) => renderAutomations(main, ui()) },
|
|
239
255
|
'price-list': { label: 'Price list', icon: 'tag', group: 'Settings', render: (main, id) => (id === 'reconcile' ? renderPriceReconcile(main, ui()) : renderPriceList(main, ui())) },
|
|
240
256
|
'custom-fields': { label: 'Custom fields', icon: 'fields', group: 'Settings', render: (main) => renderCustomFields(main, ui()) },
|
|
241
257
|
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
@@ -252,6 +268,9 @@ const VIEWS = {
|
|
|
252
268
|
const ICON_PATHS = {
|
|
253
269
|
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"/>',
|
|
254
270
|
invoices: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h7M9 17h5"/>',
|
|
271
|
+
draft: '<path d="M14 3H6v18h5M14 3l4 4v3M14 3v4h4"/><path d="M13 21v-2.5l6-6 2.5 2.5-6 6z"/>',
|
|
272
|
+
bolt: '<path d="M13 3L5 13h6l-1 8 8-10h-6z"/>',
|
|
273
|
+
rules: '<path d="M3 6l2 2 4-4M3 16l2 2 4-4M13 6h8M13 12h8M13 18h8"/>',
|
|
255
274
|
reconcile: '<path d="M4 8h13M13 4l4 4-4 4M20 16H7M11 12l-4 4 4 4"/>',
|
|
256
275
|
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
|
|
257
276
|
tag: '<path d="M3 12V4h8l10 10-8 8z"/><circle cx="7.5" cy="8.5" r="1.5"/>',
|
|
@@ -292,11 +311,11 @@ function drawMenu() {
|
|
|
292
311
|
const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the sidebar' : 'Hide the sidebar');
|
|
293
312
|
toggle.setAttribute('aria-expanded', String(open));
|
|
294
313
|
toggle.setAttribute('aria-label', label);
|
|
295
|
-
toggle.
|
|
314
|
+
toggle.dataset.tip = label;
|
|
296
315
|
// Folded, a page's name shows when the pointer rests on its icon.
|
|
297
316
|
for (const link of document.querySelectorAll('[data-nav]')) {
|
|
298
|
-
if (folded && !NARROW.matches) link.
|
|
299
|
-
else link.
|
|
317
|
+
if (folded && !NARROW.matches) Object.assign(link.dataset, { tip: link.dataset.label, tipSide: 'right' });
|
|
318
|
+
else { delete link.dataset.tip; delete link.dataset.tipSide; }
|
|
300
319
|
}
|
|
301
320
|
}
|
|
302
321
|
|
|
@@ -403,7 +422,7 @@ function navigate(event) {
|
|
|
403
422
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
404
423
|
// takes a path, or a link's click event.
|
|
405
424
|
const ui = () => ({
|
|
406
|
-
h, flow, toast, notice, pageHeader, dialog, me, holdButton,
|
|
425
|
+
h, flow, toast, notice, pageHeader, sectionTitle, dialog, me, holdButton, tileCard, crumbs,
|
|
407
426
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
408
427
|
});
|
|
409
428
|
|
|
@@ -641,9 +660,9 @@ async function renderIntegrations(main) {
|
|
|
641
660
|
const notices = h('div', { class: 'fx-fields' });
|
|
642
661
|
const content = h('div', { class: 'fx-int-sections' }, h('div', { class: 'fx-skeleton' }));
|
|
643
662
|
main.append(
|
|
644
|
-
|
|
663
|
+
pageHeader('Integrations'),
|
|
645
664
|
alertStrip(ui(), 'integrations').element,
|
|
646
|
-
h('div', { class: 'fx-int-bar' }, tabs, search),
|
|
665
|
+
h('div', { class: 'fx-int-bar' }, tabs, h('div', { class: 'fx-row' }, category, search)),
|
|
647
666
|
notices, content);
|
|
648
667
|
let body;
|
|
649
668
|
try {
|
|
@@ -655,13 +674,14 @@ async function renderIntegrations(main) {
|
|
|
655
674
|
const canChange = Boolean(me.ownerActions?.allowed);
|
|
656
675
|
notices.replaceChildren(...[
|
|
657
676
|
canChange ? null : notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.'),
|
|
658
|
-
body.secretsKey ? null : notice('Flow
|
|
677
|
+
body.secretsKey ? null : notice('Flow cannot store a client secret. Set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.'),
|
|
659
678
|
].filter(Boolean));
|
|
660
679
|
const list = [...body.integrations].sort((a, b) => a.name.localeCompare(b.name));
|
|
661
680
|
const connected = (i) => i.enabled;
|
|
662
681
|
|
|
663
682
|
const draw = () => {
|
|
664
|
-
|
|
683
|
+
category.hidden = state.tab !== 'all';
|
|
684
|
+
tabs.replaceChildren(...[['all', 'All'], ['yours', 'Connected']].map(([id, label]) => h('button', {
|
|
665
685
|
type: 'button', role: 'tab', class: 'fx-seg__item', 'aria-selected': String(state.tab === id), 'aria-checked': String(state.tab === id),
|
|
666
686
|
onclick: () => { state.tab = id; draw(); },
|
|
667
687
|
}, label)));
|
|
@@ -672,7 +692,7 @@ async function renderIntegrations(main) {
|
|
|
672
692
|
const yours = list.filter(connected).filter(matches);
|
|
673
693
|
sections.push(h('section', { class: 'fx-int-section-list' },
|
|
674
694
|
yours.length ? h('div', { class: 'fx-int-grid' }, yours.map(integrationTile))
|
|
675
|
-
: h('p', { class: 'fx-empty' }, q ? 'No integration matches.' : 'Nothing is connected yet. Pick one under All
|
|
695
|
+
: h('p', { class: 'fx-empty' }, q ? 'No integration matches.' : 'Nothing is connected yet. Pick one under All.')));
|
|
676
696
|
} else {
|
|
677
697
|
// Recommended: what is not connected yet, once the catalogue is long
|
|
678
698
|
// enough that a short row helps (with a few, it would repeat the grid).
|
|
@@ -684,7 +704,7 @@ async function renderIntegrations(main) {
|
|
|
684
704
|
}
|
|
685
705
|
const all = list.filter(matches).filter((i) => state.category === 'all' || CATALOGUE[i.id]?.category === state.category);
|
|
686
706
|
sections.push(h('section', { class: 'fx-int-section-list' },
|
|
687
|
-
|
|
707
|
+
recommended.length ? h('h2', { class: 'fx-int-heading' }, 'All') : null,
|
|
688
708
|
all.length ? h('div', { class: 'fx-int-grid' }, all.map(integrationTile)) : h('p', { class: 'fx-empty' }, 'No integration matches.')));
|
|
689
709
|
}
|
|
690
710
|
content.replaceChildren(...sections);
|
|
@@ -695,17 +715,39 @@ async function renderIntegrations(main) {
|
|
|
695
715
|
}
|
|
696
716
|
|
|
697
717
|
function integrationTile(integration) {
|
|
698
|
-
const href = `/integrations/${integration.id}`;
|
|
699
718
|
const entry = CATALOGUE[integration.id];
|
|
700
719
|
const on = integration.enabled;
|
|
701
720
|
const attention = ['failed', 'restart_needed'].includes(integration.state);
|
|
702
|
-
return
|
|
721
|
+
return tileCard({
|
|
722
|
+
href: `/integrations/${integration.id}`,
|
|
723
|
+
mark: integrationLogo(integration),
|
|
724
|
+
title: integration.name,
|
|
725
|
+
status: on ? { label: STATE_LABEL[integration.state] ?? integration.state, tone: STATE_TONE[integration.state] } : null,
|
|
726
|
+
blurb: entry?.blurb,
|
|
727
|
+
action: attention ? 'Fix' : on ? 'Manage' : '+ Connect',
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// One card in a grid of choices (Integrations, Rules): its mark (a node, or
|
|
732
|
+
// an ICON_PATHS name), the name, a status pill, two lines at most (only when
|
|
733
|
+
// they help), and the action. The whole card is the link.
|
|
734
|
+
function tileCard({ href, mark, title, status, blurb, action = 'Manage' }) {
|
|
735
|
+
const logo = typeof mark === 'string' ? h('span', { class: 'fx-logo fx-logo--icon', 'aria-hidden': 'true' }, icon(mark)) : mark;
|
|
736
|
+
return h('a', { class: `fx-int-card${blurb ? '' : ' fx-int-card--bare'}`, href, onclick: navigate },
|
|
703
737
|
h('header', { class: 'fx-int-card__head' },
|
|
704
|
-
|
|
705
|
-
h('h3', {},
|
|
706
|
-
|
|
707
|
-
h('p', { class: 'fx-int-card__blurb' },
|
|
708
|
-
h('span', { class: 'fx-int-card__btn' },
|
|
738
|
+
logo,
|
|
739
|
+
h('h3', {}, title),
|
|
740
|
+
status ? h('span', { class: 'fx-status', 'data-tone': status.tone ?? 'mute' }, status.label) : null),
|
|
741
|
+
blurb ? h('p', { class: 'fx-int-card__blurb' }, blurb) : null,
|
|
742
|
+
h('span', { class: 'fx-int-card__btn' }, action));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// The trail above a page one level down: crumbs(['/integrations', 'Integrations'], 'Xero').
|
|
746
|
+
function crumbs(...trail) {
|
|
747
|
+
const here = trail.pop();
|
|
748
|
+
return h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
|
|
749
|
+
...trail.flatMap(([href, label]) => [h('a', { href, onclick: navigate }, label), h('span', { 'aria-hidden': 'true' }, '›')]),
|
|
750
|
+
h('span', {}, here));
|
|
709
751
|
}
|
|
710
752
|
|
|
711
753
|
// One integration's page (/integrations/<id>): its logo and maker, Connect
|
|
@@ -842,8 +884,7 @@ async function renderIntegration(main, id) {
|
|
|
842
884
|
busy ? h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }) : null, busy ? 'Connecting' : 'Connect');
|
|
843
885
|
|
|
844
886
|
host.replaceChildren(
|
|
845
|
-
|
|
846
|
-
h('a', { href: '/integrations', onclick: navigate }, 'Integrations'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, current.name)),
|
|
887
|
+
crumbs(['/integrations', 'Integrations'], current.name),
|
|
847
888
|
h('header', { class: 'fx-int-hero' },
|
|
848
889
|
integrationLogo(current, 'xl'),
|
|
849
890
|
h('div', { class: 'fx-int-hero__text' },
|
|
@@ -858,7 +899,7 @@ async function renderIntegration(main, id) {
|
|
|
858
899
|
h('div', { class: 'fx-int-page__main' }, ...[
|
|
859
900
|
alerts.element,
|
|
860
901
|
!canChange ? notice('Changing integrations needs a verified owner sign-in: Google Workspace on the owner domain with no password, or a second factor.', 'warn', 'Read only.') : null,
|
|
861
|
-
!body.secretsKey ? notice('Flow
|
|
902
|
+
!body.secretsKey ? notice('Flow cannot store a client secret. Set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
|
|
862
903
|
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
863
904
|
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
864
905
|
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
@@ -1103,7 +1144,7 @@ function renderAccount(main) {
|
|
|
1103
1144
|
// in it at once.
|
|
1104
1145
|
function renderAppearance(main) {
|
|
1105
1146
|
main.append(
|
|
1106
|
-
pageHeader('Appearance'
|
|
1147
|
+
pageHeader('Appearance'),
|
|
1107
1148
|
h('div', { class: 'fx-appearance', id: 'appearance' }));
|
|
1108
1149
|
drawAppearance();
|
|
1109
1150
|
}
|
|
@@ -1169,13 +1210,16 @@ function drawAppearance() {
|
|
|
1169
1210
|
(p) => chooseAppearance({ accent: p.hex ? p.id : null }),
|
|
1170
1211
|
(p) => p.hex
|
|
1171
1212
|
? h('button', { type: 'button', class: 'fx-swatch', title: p.label, 'aria-label': p.label, style: { background: p.hex } })
|
|
1172
|
-
|
|
1173
|
-
|
|
1213
|
+
// The design system's own accent, named "Default" on the swatch: a
|
|
1214
|
+
// click puts it back.
|
|
1215
|
+
: h('button', { type: 'button', class: 'fx-swatch fx-swatch--default', 'aria-label': `Default: ${active.label}'s own accent` },
|
|
1216
|
+
h('span', { class: 'fx-swatch__dot', 'aria-hidden': 'true', style: { background: `linear-gradient(135deg, ${active.swatches[mode].primary} 50%, ${active.swatches[mode].accent} 50%)` } }),
|
|
1217
|
+
'Default'));
|
|
1174
1218
|
accents.classList.add('fx-swatches');
|
|
1175
1219
|
const custom = h('label', { class: 'fx-swatch-custom' },
|
|
1176
1220
|
h('input', { type: 'color', 'aria-label': 'Custom accent colour', value: customHex ?? '#0d4e5f', onchange: (event) => chooseAppearance({ accent: event.target.value }) }),
|
|
1177
1221
|
'Custom');
|
|
1178
|
-
const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label :
|
|
1222
|
+
const accentLabel = customHex ? `Custom ${customHex}` : presetIndex > 0 ? ACCENT_PRESETS[presetIndex].label : `the default, ${active.label}'s own`;
|
|
1179
1223
|
|
|
1180
1224
|
host.replaceChildren(h('div', { class: 'fx-settings' },
|
|
1181
1225
|
settingRow({ label: 'Mode', hint: 'Auto follows this device.', control: modeGroup }),
|
|
@@ -1230,7 +1274,7 @@ function drawModeToggles() {
|
|
|
1230
1274
|
button.innerHTML = dark ? SUN : MOON;
|
|
1231
1275
|
if (!button.classList.contains('fx-btn--icon')) button.append(` ${dark ? 'Light' : 'Dark'}`);
|
|
1232
1276
|
button.setAttribute('aria-label', label);
|
|
1233
|
-
button.
|
|
1277
|
+
button.dataset.tip = label;
|
|
1234
1278
|
}
|
|
1235
1279
|
}
|
|
1236
1280
|
|
|
@@ -1291,4 +1335,5 @@ async function start() {
|
|
|
1291
1335
|
await renderShell();
|
|
1292
1336
|
}
|
|
1293
1337
|
|
|
1338
|
+
startTips();
|
|
1294
1339
|
start();
|
package/server/ui/automations.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Automation rows (server/core/automations.js, /api/invoicing/automations).
|
|
2
2
|
// Each automation shows where its trigger lives: the work system's ready rule
|
|
3
|
-
// on that integration's page, the
|
|
3
|
+
// on that integration's page, the invoicing ones on Settings, Automations. One row
|
|
4
4
|
// for each: its name, one short line, and its switch, plus a row for each of
|
|
5
5
|
// its settings. A change saves at once. Everyone can read them; an owner or
|
|
6
6
|
// admin changes them. The caller passes in app.js's shared pieces as `ui`.
|
|
@@ -86,11 +86,17 @@ export function automationSection(ui, place, title) {
|
|
|
86
86
|
(async () => {
|
|
87
87
|
try {
|
|
88
88
|
const { automations } = await flow(`/api/invoicing/automations?place=${encodeURIComponent(place)}`);
|
|
89
|
-
section.replaceChildren(
|
|
90
|
-
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How automations work')));
|
|
89
|
+
section.replaceChildren(ui.sectionTitle(title, { href: DOCS, label: 'How automations work' }), ...automations.flatMap(rowsOf));
|
|
91
90
|
} catch (error) {
|
|
92
91
|
section.replaceChildren(h('h2', { class: 'fx-set__title' }, title), notice(error.message, 'danger'));
|
|
93
92
|
}
|
|
94
93
|
})();
|
|
95
94
|
return section;
|
|
96
95
|
}
|
|
96
|
+
|
|
97
|
+
// Settings, Automations (/automations): the invoicing automations. A work
|
|
98
|
+
// system's own automations stay on its integration page.
|
|
99
|
+
export function renderAutomations(main, ui) {
|
|
100
|
+
const { h, pageHeader } = ui;
|
|
101
|
+
main.append(pageHeader('Automations'), h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui, 'invoicing', 'Invoicing')));
|
|
102
|
+
}
|