@arcforgelabs/flow 2026.10.26 → 2026.10.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +2 -1
- package/server/api/invoicing.js +231 -20
- package/server/app.js +28 -2
- package/server/core/automations.js +140 -0
- package/server/core/invoice-rules.js +224 -0
- package/server/core/invoices.js +4 -1
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/ledger.js +45 -12
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +8 -1
- package/server/core/sending.js +282 -0
- package/server/db/migrations/0010_invoicing_rules.sql +51 -0
- package/server/db/migrations/0011_invoice_rules.sql +21 -0
- package/server/db/migrations/0012_invoice_sending.sql +9 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/_journal.json +21 -0
- package/server/db/schema.js +115 -0
- package/server/index.js +12 -4
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +5 -0
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +123 -16
- package/server/integrations/xero/index.js +11 -1
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/ui/app-page.js +2 -1
- package/server/ui/app.js +14 -1
- package/server/ui/automations.js +96 -0
- package/server/ui/flow.css +88 -0
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +108 -0
- package/server/ui/invoicing.js +166 -20
- package/server/ui/jobs.js +19 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/monitoring.js +53 -0
- package/server/ui/routes.js +2 -2
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- How each push was sent (invoicing_settings.send_mode) and, for an invoice
|
|
2
|
+
-- sent approved, whether Xero emailed it to the customer. Xero's email action
|
|
3
|
+
-- is not idempotent, so Flow records `sending` before the call and retries
|
|
4
|
+
-- only a refusal (`failed`), never an unknown outcome.
|
|
5
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN "send_mode" text DEFAULT 'accounting_draft' NOT NULL;--> statement-breakpoint
|
|
6
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN "email_state" text;--> statement-breakpoint
|
|
7
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN "email_error" text;--> statement-breakpoint
|
|
8
|
+
ALTER TABLE "xero_invoice_pushes" ADD COLUMN "emailed_at" timestamp with time zone;--> statement-breakpoint
|
|
9
|
+
ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_send_mode_valid" CHECK ("send_mode" in ('accounting_draft', 'approve_and_email'));--> statement-breakpoint
|
|
10
|
+
ALTER TABLE "xero_invoice_pushes" ADD CONSTRAINT "xero_invoice_pushes_email_state_valid" CHECK ("email_state" is null or "email_state" in ('pending', 'sending', 'sent', 'failed', 'unknown'));
|
package/server/ui/app-page.js
CHANGED
|
@@ -40,6 +40,7 @@ const BODY = `<div class="fx-app">
|
|
|
40
40
|
<nav class="fx-nav" id="nav" aria-label="Flow"></nav>
|
|
41
41
|
<div class="fx-side__end">
|
|
42
42
|
<div id="user-button" class="fx-side__user"></div>
|
|
43
|
+
<button type="button" class="fx-btn fx-btn--ghost fx-btn--icon" id="report-problem" title="Report a problem" aria-label="Report a problem" hidden></button>
|
|
43
44
|
<button type="button" class="fx-btn fx-btn--ghost fx-btn--icon fx-mode-toggle" data-mode-toggle>Light or dark</button>
|
|
44
45
|
</div>
|
|
45
46
|
</aside>
|
|
@@ -48,7 +49,7 @@ const BODY = `<div class="fx-app">
|
|
|
48
49
|
<div id="toasts" class="fx-toasts" aria-live="polite"></div>
|
|
49
50
|
</div>`;
|
|
50
51
|
|
|
51
|
-
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance'];
|
|
52
|
+
export const APP_PATHS = ['/sign-in', '/jobs', '/invoices', '/invoices/:id', '/people', '/integrations', '/integrations/:id', '/account', '/appearance', '/invoicing-settings'];
|
|
52
53
|
|
|
53
54
|
export function appPage({ defaultFamily }) {
|
|
54
55
|
return renderPage({
|
package/server/ui/app.js
CHANGED
|
@@ -10,7 +10,10 @@ 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';
|
|
14
|
+
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
13
15
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
16
|
+
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
14
17
|
|
|
15
18
|
const $ = (id) => document.getElementById(id);
|
|
16
19
|
|
|
@@ -190,6 +193,7 @@ let me = null;
|
|
|
190
193
|
const VIEWS = {
|
|
191
194
|
jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
|
|
192
195
|
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
196
|
+
'invoicing-settings': { label: 'Invoicing', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
|
|
193
197
|
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
194
198
|
integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
195
199
|
account: { label: 'Account', icon: 'person', group: 'Settings', render: renderAccount },
|
|
@@ -203,6 +207,7 @@ const ICON_PATHS = {
|
|
|
203
207
|
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"/>',
|
|
204
208
|
plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
|
|
205
209
|
person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
|
|
210
|
+
flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
|
|
206
211
|
palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
|
|
207
212
|
};
|
|
208
213
|
|
|
@@ -285,6 +290,11 @@ function renderShell() {
|
|
|
285
290
|
}
|
|
286
291
|
nav.replaceChildren(...items);
|
|
287
292
|
mountClerk('userButton', $('user-button'), { afterSignOutUrl: '/sign-in', showName: false });
|
|
293
|
+
// Errors and problem reports go to the installation's own Sentry, when the
|
|
294
|
+
// Sentry integration is on (monitoring.js). Flow works the same without it.
|
|
295
|
+
$('report-problem').replaceChildren(icon('flag'));
|
|
296
|
+
startMonitoring({ flow, person: me.person, button: $('report-problem') })
|
|
297
|
+
.catch((error) => console.warn(`[monitoring] ${error.message}`));
|
|
288
298
|
window.addEventListener('popstate', route);
|
|
289
299
|
return route();
|
|
290
300
|
}
|
|
@@ -405,8 +415,9 @@ const STATE_TONE = { ready: 'success', disabled: 'mute', failed: 'danger', resta
|
|
|
405
415
|
const CATALOGUE = {
|
|
406
416
|
xero: { maker: 'Xero', category: 'accounting', blurb: 'Send invoices to Xero as drafts and bring payments back.', logo: 'xero.svg' },
|
|
407
417
|
seazona: { maker: 'Seazona', category: 'lab', blurb: 'Bring lab cases and orders into Flow as jobs.', logo: 'seazona.svg' },
|
|
418
|
+
sentry: { maker: 'Sentry', category: 'monitoring', blurb: 'Send errors and problem reports to your own Sentry project.', logo: 'sentry.svg' },
|
|
408
419
|
};
|
|
409
|
-
const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software']];
|
|
420
|
+
const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software'], ['monitoring', 'Monitoring']];
|
|
410
421
|
const CATEGORY_LABEL = Object.fromEntries(CATEGORIES);
|
|
411
422
|
|
|
412
423
|
function integrationLogo(integration, size = '') {
|
|
@@ -626,6 +637,8 @@ async function renderIntegration(main, id) {
|
|
|
626
637
|
!body.secretsKey ? notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
|
|
627
638
|
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
628
639
|
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
640
|
+
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
641
|
+
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
|
|
629
642
|
guide ? h('section', { class: 'fx-int-section' },
|
|
630
643
|
h('h2', { class: 'fx-int-section__title' }, 'Set up'),
|
|
631
644
|
h('ol', { class: 'fx-steps' }, guide.steps.map((step) => h('li', {}, step))),
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Automation rows (server/core/automations.js, /api/invoicing/automations).
|
|
2
|
+
// Each automation shows where its trigger lives: the work system's ready rule
|
|
3
|
+
// on that integration's page, the draft rule on Settings, Invoicing. One row
|
|
4
|
+
// for each: its name, one short line, and its switch, plus a row for each of
|
|
5
|
+
// its settings. A change saves at once. Everyone can read them; an owner or
|
|
6
|
+
// admin changes them. The caller passes in app.js's shared pieces as `ui`.
|
|
7
|
+
|
|
8
|
+
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/AUTOMATIONS.md';
|
|
9
|
+
|
|
10
|
+
// A section of the automations kept at `place`, filled when they load.
|
|
11
|
+
export function automationSection(ui, place, title) {
|
|
12
|
+
const { h, flow, toast, notice, me } = ui;
|
|
13
|
+
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
|
|
14
|
+
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
15
|
+
|
|
16
|
+
async function save(key, changes, control) {
|
|
17
|
+
control.disabled = true;
|
|
18
|
+
try {
|
|
19
|
+
const { automation } = await flow(`/api/invoicing/automations/${key}`, { method: 'PUT', body: JSON.stringify(changes) });
|
|
20
|
+
toast('Saved.', 'success');
|
|
21
|
+
return automation;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
toast(error.message, 'danger');
|
|
24
|
+
return null;
|
|
25
|
+
} finally {
|
|
26
|
+
control.disabled = disabled;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
|
|
31
|
+
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
|
|
32
|
+
h('div', { class: 'fx-set__control' }, control));
|
|
33
|
+
|
|
34
|
+
// A menu of the statuses the work system reported, each with its job count.
|
|
35
|
+
// A status saved earlier stays in the menu even when no job has it now.
|
|
36
|
+
const statusPicker = (automation) => {
|
|
37
|
+
const summary = h('summary', { class: 'fx-select fx-multi__summary' });
|
|
38
|
+
const list = h('div', { class: 'fx-multi__list', role: 'group', 'aria-label': 'Ready statuses' }, h('div', { class: 'fx-skeleton' }));
|
|
39
|
+
const picker = h('details', { class: 'fx-multi' }, summary, list);
|
|
40
|
+
let options = [];
|
|
41
|
+
const show = () => {
|
|
42
|
+
const chosen = new Set(automation.config.statuses);
|
|
43
|
+
const names = options.filter((o) => chosen.has(o.value)).map((o) => o.label);
|
|
44
|
+
summary.textContent = names.length ? names.join(', ') : 'None';
|
|
45
|
+
list.replaceChildren(...(options.length ? options.map((o) => {
|
|
46
|
+
const box = h('input', { type: 'checkbox', checked: chosen.has(o.value), disabled });
|
|
47
|
+
box.addEventListener('change', async () => {
|
|
48
|
+
const next = options.map((x) => x.value).filter((v) => (v === o.value ? box.checked : chosen.has(v)));
|
|
49
|
+
if (!next.length) {
|
|
50
|
+
box.checked = true;
|
|
51
|
+
toast('Pick at least one status. To stop it, turn the automation off.', 'danger');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const saved = await save(automation.key, { config: { statuses: next } }, box);
|
|
55
|
+
if (saved) automation.config = saved.config;
|
|
56
|
+
show();
|
|
57
|
+
});
|
|
58
|
+
return h('label', { class: 'fx-multi__option' }, box, h('span', {}, o.label), h('span', { class: 'fx-dim' }, String(o.jobs)));
|
|
59
|
+
}) : [h('p', { class: 'fx-dim', style: { margin: 0 } }, 'Seazona has reported no statuses yet.')]));
|
|
60
|
+
};
|
|
61
|
+
(async () => {
|
|
62
|
+
try {
|
|
63
|
+
({ statuses: options } = await flow('/api/invoicing/work-statuses'));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
options = [];
|
|
66
|
+
toast(error.message, 'danger');
|
|
67
|
+
}
|
|
68
|
+
const known = new Set(options.map((o) => o.value));
|
|
69
|
+
for (const value of automation.config.statuses) if (!known.has(value)) options.push({ value, label: value, jobs: 0 });
|
|
70
|
+
show();
|
|
71
|
+
})();
|
|
72
|
+
return picker;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const rowsOf = (automation) => {
|
|
76
|
+
const button = h('button', { class: 'fx-switch', type: 'button', role: 'switch', 'aria-label': automation.name, 'aria-checked': String(automation.enabled), disabled });
|
|
77
|
+
button.addEventListener('click', async () => {
|
|
78
|
+
const saved = await save(automation.key, { enabled: button.getAttribute('aria-checked') !== 'true' }, button);
|
|
79
|
+
if (saved) button.setAttribute('aria-checked', String(saved.enabled));
|
|
80
|
+
});
|
|
81
|
+
const out = [row(automation.name, automation.hint, button)];
|
|
82
|
+
if (automation.config.statuses) out.push(row('Ready statuses', 'Picked from the statuses Seazona reports.', statusPicker(automation)));
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
(async () => {
|
|
87
|
+
try {
|
|
88
|
+
const { automations } = await flow(`/api/invoicing/automations?place=${encodeURIComponent(place)}`);
|
|
89
|
+
section.replaceChildren(h('h2', { class: 'fx-set__title' }, title), ...automations.flatMap(rowsOf),
|
|
90
|
+
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How automations work')));
|
|
91
|
+
} catch (error) {
|
|
92
|
+
section.replaceChildren(h('h2', { class: 'fx-set__title' }, title), notice(error.message, 'danger'));
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
return section;
|
|
96
|
+
}
|
package/server/ui/flow.css
CHANGED
|
@@ -295,6 +295,8 @@ a.fx-strong { text-decoration: none; }
|
|
|
295
295
|
a.fx-strong:hover { text-decoration: underline; }
|
|
296
296
|
.fx-toolbar { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--fx-line); }
|
|
297
297
|
.fx-tabs { display: flex; gap: 2px; }
|
|
298
|
+
.fx-tabs button { flex: none; }
|
|
299
|
+
@media (max-width: 640px) { .fx-tabs { max-width: 100%; overflow-x: auto; scrollbar-width: none; } }
|
|
298
300
|
.fx-tabs button {
|
|
299
301
|
display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; margin-bottom: -1px;
|
|
300
302
|
border: 0; border-bottom: 2px solid transparent; background: transparent; cursor: pointer;
|
|
@@ -303,6 +305,15 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
303
305
|
.fx-tabs button:hover { color: var(--t-strong); }
|
|
304
306
|
.fx-tabs button[aria-selected="true"] { color: var(--t-strong); border-bottom-color: var(--t-brand); }
|
|
305
307
|
.fx-tabs__count { padding: 0 7px; border-radius: 999px; background: var(--fx-hover); color: var(--t-dim); font-size: 12px; line-height: 20px; }
|
|
308
|
+
.fx-tabs button[data-tone="danger"] .fx-tabs__count { background: var(--t-danger-bg); color: var(--t-danger); }
|
|
309
|
+
/* Invoices: the Send all ready bar above the Ready list. */
|
|
310
|
+
.fx-sendbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 12px; padding: 12px 16px; border: 1px solid var(--fx-line); border-radius: 8px; background: var(--t-paper); margin-top: 12px; }
|
|
311
|
+
.fx-sendbar[hidden] { display: none; }
|
|
312
|
+
.fx-sendbar p { margin: 0; }
|
|
313
|
+
.fx-problem { color: var(--t-danger); }
|
|
314
|
+
.fx-sendbar__actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
315
|
+
.fx-tick-cell { width: 40px; }
|
|
316
|
+
.fx-tick { width: 16px; height: 16px; accent-color: var(--t-brand); cursor: pointer; }
|
|
306
317
|
.fx-search { width: min(320px, 100%); margin-bottom: 8px; }
|
|
307
318
|
.fx-table--rows tbody tr { cursor: pointer; }
|
|
308
319
|
.fx-table th.fx-num { text-align: right; }
|
|
@@ -536,3 +547,80 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
536
547
|
@media (prefers-reduced-motion: reduce) {
|
|
537
548
|
*, *::before, *::after { animation: none !important; transition: none !important; }
|
|
538
549
|
}
|
|
550
|
+
|
|
551
|
+
/* ── Settings rows ───────────────────────────────────────────────────────
|
|
552
|
+
One row for each setting: its name, one short line under it, and the
|
|
553
|
+
control on the right. */
|
|
554
|
+
.fx-set { display: grid; gap: 4px; }
|
|
555
|
+
.fx-set__title { margin: 8px 0 4px; color: var(--t-strong); font: 600 15px/1.3 var(--t-body); }
|
|
556
|
+
.fx-set__row { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 12px 0; }
|
|
557
|
+
.fx-set__row + .fx-set__row { border-top: 1px solid var(--fx-line); }
|
|
558
|
+
.fx-set__text { display: grid; gap: 2px; min-width: 0; }
|
|
559
|
+
.fx-set__name { color: var(--t-text); font: 500 14px/1.35 var(--t-body); }
|
|
560
|
+
.fx-set__hint { color: var(--t-dim); font-size: 13px; line-height: 1.35; }
|
|
561
|
+
.fx-set__control { flex: none; display: flex; justify-content: flex-end; }
|
|
562
|
+
.fx-set__control .fx-input { width: 220px; }
|
|
563
|
+
.fx-set__control .fx-input[type="number"] { width: 96px; }
|
|
564
|
+
.fx-set__control .fx-select { min-width: 220px; }
|
|
565
|
+
.fx-set + .fx-set { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--fx-line); }
|
|
566
|
+
/* Invoice rules (invoice-rules.js): a row's Edit button and switch, and the
|
|
567
|
+
rule dialog's condition lines. */
|
|
568
|
+
.fx-rule__controls { align-items: center; gap: 10px; }
|
|
569
|
+
.fx-rule__footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
|
|
570
|
+
.fx-dialog:has(.fx-rule__dialog) { width: min(680px, calc(100vw - 32px)); }
|
|
571
|
+
.fx-rule__dialog .fx-btn--sm { justify-self: start; }
|
|
572
|
+
.fx-rule__conditions { display: grid; gap: 8px; }
|
|
573
|
+
.fx-rule__condition { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.4fr) auto; gap: 8px; align-items: start; }
|
|
574
|
+
.fx-rule__condition + .fx-rule__condition { padding-top: 8px; border-top: 1px solid var(--fx-line); }
|
|
575
|
+
.fx-rule__condition .fx-select, .fx-rule__condition .fx-input { width: 100%; min-width: 0; }
|
|
576
|
+
.fx-rule__value { display: flex; gap: 6px; align-items: center; min-width: 0; }
|
|
577
|
+
.fx-rule__many { display: grid; gap: 2px; max-height: 160px; overflow: auto; padding: 4px 8px; border: 1px solid var(--fx-line); border-radius: 8px; width: 100%; }
|
|
578
|
+
.fx-rule__delete { justify-self: start; color: var(--t-danger); }
|
|
579
|
+
@media (max-width: 640px) {
|
|
580
|
+
.fx-rule__condition { grid-template-columns: minmax(0, 1fr); }
|
|
581
|
+
}
|
|
582
|
+
@media (max-width: 560px) {
|
|
583
|
+
.fx-set__row { gap: 12px; }
|
|
584
|
+
.fx-set__control .fx-input { width: 140px; }
|
|
585
|
+
.fx-set__control .fx-input[type="number"] { width: 72px; }
|
|
586
|
+
.fx-set__control .fx-select { min-width: 140px; }
|
|
587
|
+
}
|
|
588
|
+
.fx-multi { position: relative; width: 220px; max-width: 100%; }
|
|
589
|
+
.fx-multi__summary { display: flex; align-items: center; list-style: none; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
590
|
+
@media (max-width: 600px) { .fx-multi { width: 150px; } }
|
|
591
|
+
.fx-multi__summary::-webkit-details-marker { display: none; }
|
|
592
|
+
.fx-multi__list { position: absolute; right: 0; top: calc(100% + 4px); z-index: 30; display: grid; gap: 2px; min-width: 100%; max-height: 280px; overflow-y: auto; padding: 6px; background: var(--t-paper); border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); box-shadow: var(--t-shadow); }
|
|
593
|
+
.fx-multi__option { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 6px 8px; cursor: pointer; white-space: nowrap; }
|
|
594
|
+
.fx-multi__option:hover { background: var(--fx-line-soft); }
|
|
595
|
+
.fx-switch {
|
|
596
|
+
position: relative; width: 40px; height: 22px; padding: 0; flex: none; cursor: pointer;
|
|
597
|
+
border: 0; border-radius: 999px; background: var(--t-control);
|
|
598
|
+
}
|
|
599
|
+
.fx-switch::after {
|
|
600
|
+
content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px;
|
|
601
|
+
border-radius: 50%; background: var(--t-paper); transition: transform 0.15s;
|
|
602
|
+
}
|
|
603
|
+
.fx-switch[aria-checked="true"] { background: var(--t-primary); }
|
|
604
|
+
.fx-switch[aria-checked="true"]::after { transform: translateX(18px); }
|
|
605
|
+
.fx-switch:focus-visible { outline: none; box-shadow: var(--fx-focus); }
|
|
606
|
+
.fx-switch:disabled { cursor: default; opacity: 0.6; }
|
|
607
|
+
|
|
608
|
+
/* ── Sentry's report form (monitoring.js) ──────────────────────────────────
|
|
609
|
+
The form lives in a shadow root and reads these variables from its host.
|
|
610
|
+
Rules here win over the form's own defaults, so it takes each person's
|
|
611
|
+
theme and mode, and follows a change at once. */
|
|
612
|
+
#sentry-feedback {
|
|
613
|
+
--font-family: var(--t-body);
|
|
614
|
+
--foreground: var(--t-text);
|
|
615
|
+
--background: var(--t-paper);
|
|
616
|
+
--accent-foreground: var(--t-on-primary);
|
|
617
|
+
--accent-background: var(--t-primary);
|
|
618
|
+
--success-color: var(--t-success);
|
|
619
|
+
--error-color: var(--t-danger);
|
|
620
|
+
--border: 1px solid var(--t-control);
|
|
621
|
+
--box-shadow: var(--t-shadow);
|
|
622
|
+
--outline: 2px solid var(--t-primary);
|
|
623
|
+
--interactive-filter: brightness(95%);
|
|
624
|
+
color-scheme: light;
|
|
625
|
+
}
|
|
626
|
+
[data-mode="dark"] #sentry-feedback { --interactive-filter: brightness(150%); color-scheme: dark; }
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// The "Rules" section of Settings, Invoicing (server/core/invoice-rules.js,
|
|
2
|
+
// /api/invoicing/rules). Each rule a lab builds is a row: its name, one short
|
|
3
|
+
// line that reads its conditions, and its switch. Edit opens a dialog where
|
|
4
|
+
// every part is picked from a menu: the field, the test, and a value from
|
|
5
|
+
// what Flow already holds (Seazona's statuses, the customers, the price list).
|
|
6
|
+
// Everyone can read the rules; an owner or admin changes them. The caller
|
|
7
|
+
// passes in app.js's shared pieces as `ui`.
|
|
8
|
+
|
|
9
|
+
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/INVOICE_RULES.md';
|
|
10
|
+
|
|
11
|
+
const TEST_LABELS = {
|
|
12
|
+
more_than: 'is more than', at_most: 'is at most', is: 'is', is_not: 'is not', is_one_of: 'is one of',
|
|
13
|
+
is_empty: 'is empty', is_not_empty: 'is not empty', has: 'has', has_not: 'does not have',
|
|
14
|
+
};
|
|
15
|
+
const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft' };
|
|
16
|
+
const NO_VALUE = ['is_empty', 'is_not_empty'];
|
|
17
|
+
|
|
18
|
+
// A rule as one short line: "Job age (days) is more than 90 · Wait for a person".
|
|
19
|
+
export function ruleSummary(rule, meta) {
|
|
20
|
+
const fieldOf = new Map(meta.fields.map((f) => [f.key, f]));
|
|
21
|
+
const labelOf = (field, value) => {
|
|
22
|
+
const option = (meta.options[field.options] ?? []).find((o) => String(o.value).toLowerCase() === String(value).toLowerCase());
|
|
23
|
+
return option?.label ?? String(value);
|
|
24
|
+
};
|
|
25
|
+
const part = (c) => {
|
|
26
|
+
const field = fieldOf.get(c.field);
|
|
27
|
+
if (!field) return c.field;
|
|
28
|
+
const test = TEST_LABELS[c.test] ?? c.test;
|
|
29
|
+
if (NO_VALUE.includes(c.test)) return `${field.label} ${test}`;
|
|
30
|
+
if (field.kind === 'boolean') return `${field.label} ${test} ${c.value ? 'yes' : 'no'}`;
|
|
31
|
+
if (field.kind === 'share') return `${labelOf(field, c.item).split(' · ')[0]} share ${test} ${c.value}%`;
|
|
32
|
+
if (field.kind === 'money') return `${field.label} ${test} $${Number(c.value).toFixed(2)}`;
|
|
33
|
+
const value = Array.isArray(c.value) ? c.value.map((v) => labelOf(field, v)).join(', ') : labelOf(field, c.value);
|
|
34
|
+
return `${field.label} ${test} ${value}`;
|
|
35
|
+
};
|
|
36
|
+
return `${rule.conditions.map(part).join(' and ')} · ${ACTION_LABELS[rule.action.kind] ?? rule.action.kind}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function rulesSection(ui, title = 'Rules') {
|
|
40
|
+
const { h, flow, toast, notice, dialog, me } = ui;
|
|
41
|
+
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
42
|
+
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
|
|
43
|
+
let meta;
|
|
44
|
+
let rules = [];
|
|
45
|
+
|
|
46
|
+
const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
|
|
47
|
+
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
|
|
48
|
+
h('div', { class: 'fx-set__control fx-rule__controls' }, control));
|
|
49
|
+
|
|
50
|
+
async function send(path, method, payload) {
|
|
51
|
+
try {
|
|
52
|
+
const body = await flow(path, { method, ...(payload ? { body: JSON.stringify(payload) } : {}) });
|
|
53
|
+
toast('Saved.', 'success');
|
|
54
|
+
return body;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
toast(error.message, 'danger');
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// One condition's controls: field, test and value, each a menu where Flow
|
|
62
|
+
// knows the values. `read()` answers the condition as the API takes it.
|
|
63
|
+
function conditionEditor(start, onRemove) {
|
|
64
|
+
const fieldSelect = h('select', { class: 'fx-select', 'aria-label': 'Field' },
|
|
65
|
+
meta.fields.map((f) => h('option', { value: f.key }, f.label)));
|
|
66
|
+
const testSelect = h('select', { class: 'fx-select', 'aria-label': 'Test' });
|
|
67
|
+
const valueHost = h('div', { class: 'fx-rule__value' });
|
|
68
|
+
let readValue = () => ({});
|
|
69
|
+
|
|
70
|
+
const options = (field) => meta.options[field.options] ?? [];
|
|
71
|
+
const choiceSelect = (field, value, label) => {
|
|
72
|
+
const list = options(field);
|
|
73
|
+
const select = h('select', { class: 'fx-select', 'aria-label': label },
|
|
74
|
+
list.map((o) => h('option', { value: o.value }, o.label)));
|
|
75
|
+
if (value !== undefined && !list.some((o) => String(o.value).toLowerCase() === String(value).toLowerCase())) {
|
|
76
|
+
select.append(h('option', { value }, String(value)));
|
|
77
|
+
}
|
|
78
|
+
if (value !== undefined) select.value = list.find((o) => String(o.value).toLowerCase() === String(value).toLowerCase())?.value ?? value;
|
|
79
|
+
return select;
|
|
80
|
+
};
|
|
81
|
+
const numberBox = (value, label) => h('input', { class: 'fx-input', type: 'number', min: '0', step: 'any', value: value ?? '', 'aria-label': label });
|
|
82
|
+
|
|
83
|
+
function drawValue(value, item) {
|
|
84
|
+
const field = meta.fields.find((f) => f.key === fieldSelect.value);
|
|
85
|
+
const test = testSelect.value;
|
|
86
|
+
valueHost.replaceChildren();
|
|
87
|
+
if (NO_VALUE.includes(test)) {
|
|
88
|
+
readValue = () => ({});
|
|
89
|
+
} else if (field.kind === 'boolean') {
|
|
90
|
+
const select = h('select', { class: 'fx-select', 'aria-label': 'Value' }, h('option', { value: 'true' }, 'Yes'), h('option', { value: 'false' }, 'No'));
|
|
91
|
+
select.value = String(value ?? false);
|
|
92
|
+
valueHost.append(select);
|
|
93
|
+
readValue = () => ({ value: select.value === 'true' });
|
|
94
|
+
} else if (field.kind === 'share') {
|
|
95
|
+
const items = choiceSelect(field, item, 'Line item');
|
|
96
|
+
const box = numberBox(value ?? 20, 'Percent of the other lines');
|
|
97
|
+
valueHost.append(items, box, h('span', { class: 'fx-dim' }, '%'));
|
|
98
|
+
readValue = () => ({ item: items.value, value: Number(box.value) });
|
|
99
|
+
} else if (['number', 'money'].includes(field.kind)) {
|
|
100
|
+
const box = numberBox(value, 'Value');
|
|
101
|
+
valueHost.append(box);
|
|
102
|
+
readValue = () => ({ value: Number(box.value) });
|
|
103
|
+
} else if (test === 'is_one_of') {
|
|
104
|
+
const chosen = new Set((Array.isArray(value) ? value : []).map((v) => String(v).toLowerCase()));
|
|
105
|
+
const boxes = options(field).map((o) => {
|
|
106
|
+
const box = h('input', { type: 'checkbox', value: o.value, checked: chosen.has(String(o.value).toLowerCase()) });
|
|
107
|
+
return h('label', { class: 'fx-multi__option' }, box, h('span', {}, o.label));
|
|
108
|
+
});
|
|
109
|
+
valueHost.append(h('div', { class: 'fx-rule__many' }, ...(boxes.length ? boxes : [h('span', { class: 'fx-dim' }, 'No values yet.')])));
|
|
110
|
+
readValue = () => ({ value: boxes.map((b) => b.querySelector('input')).filter((b) => b.checked).map((b) => b.value) });
|
|
111
|
+
} else if (field.options) {
|
|
112
|
+
const select = choiceSelect(field, Array.isArray(value) ? value[0] : value, 'Value');
|
|
113
|
+
valueHost.append(select);
|
|
114
|
+
readValue = () => ({ value: select.value });
|
|
115
|
+
} else {
|
|
116
|
+
const box = h('input', { class: 'fx-input', type: 'text', value: value ?? '', 'aria-label': 'Value' });
|
|
117
|
+
valueHost.append(box);
|
|
118
|
+
readValue = () => ({ value: box.value });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function drawTests(test) {
|
|
123
|
+
const field = meta.fields.find((f) => f.key === fieldSelect.value);
|
|
124
|
+
testSelect.replaceChildren(...field.tests.map((t) => h('option', { value: t }, TEST_LABELS[t] ?? t)));
|
|
125
|
+
testSelect.value = field.tests.includes(test) ? test : field.tests[0];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fieldSelect.value = start?.field ?? meta.fields[0].key;
|
|
129
|
+
drawTests(start?.test);
|
|
130
|
+
drawValue(start?.value, start?.item);
|
|
131
|
+
fieldSelect.addEventListener('change', () => { drawTests(); drawValue(); });
|
|
132
|
+
testSelect.addEventListener('change', () => drawValue());
|
|
133
|
+
|
|
134
|
+
const remove = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', 'aria-label': 'Remove condition' }, 'Remove');
|
|
135
|
+
const element = h('div', { class: 'fx-rule__condition' }, fieldSelect, testSelect, valueHost, remove);
|
|
136
|
+
remove.addEventListener('click', () => onRemove(element));
|
|
137
|
+
return { element, read: () => ({ field: fieldSelect.value, test: testSelect.value, ...readValue() }) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The dialog for a new rule (rule null) or an existing one. `draft` fills
|
|
141
|
+
// it again after a refused save, so no choice is lost.
|
|
142
|
+
async function edit(rule, draft = rule) {
|
|
143
|
+
const name = h('input', { class: 'fx-input', name: 'name', required: true, maxlength: '120', value: draft?.name ?? '', 'aria-label': 'Rule name' });
|
|
144
|
+
const action = h('select', { class: 'fx-select', 'aria-label': 'Then' },
|
|
145
|
+
Object.entries(ACTION_LABELS).map(([value, label]) => h('option', { value }, label)));
|
|
146
|
+
action.value = draft?.action.kind ?? 'wait';
|
|
147
|
+
let editors = [];
|
|
148
|
+
const list = h('div', { class: 'fx-rule__conditions' });
|
|
149
|
+
const addCondition = (start) => {
|
|
150
|
+
if (editors.length >= 10) return toast('A rule has 10 conditions at most.', 'danger');
|
|
151
|
+
const editor = conditionEditor(start, (element) => {
|
|
152
|
+
if (editors.length === 1) return toast('A rule needs at least one condition.', 'danger');
|
|
153
|
+
editors = editors.filter((e) => e.element !== element);
|
|
154
|
+
element.remove();
|
|
155
|
+
return null;
|
|
156
|
+
});
|
|
157
|
+
editors.push(editor);
|
|
158
|
+
list.append(editor.element);
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
for (const c of draft?.conditions ?? [null]) addCondition(c);
|
|
162
|
+
const more = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Add condition');
|
|
163
|
+
more.addEventListener('click', () => addCondition());
|
|
164
|
+
|
|
165
|
+
let wantsDelete = false;
|
|
166
|
+
const remove = rule ? h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm fx-rule__delete', type: 'button' }, 'Delete rule') : null;
|
|
167
|
+
remove?.addEventListener('click', () => {
|
|
168
|
+
wantsDelete = true;
|
|
169
|
+
remove.closest('dialog').close();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const label = (text, ...children) => h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, text), ...children);
|
|
173
|
+
const data = await dialog({
|
|
174
|
+
title: rule ? 'Edit rule' : 'New rule',
|
|
175
|
+
body: h('div', { class: 'fx-fields fx-rule__dialog' },
|
|
176
|
+
label('Name', name),
|
|
177
|
+
label('When', h('span', { class: 'fx-dim' }, 'A job is ready to invoice and has no invoice.')),
|
|
178
|
+
label('If all of these match', list, more),
|
|
179
|
+
label('Then', action),
|
|
180
|
+
remove),
|
|
181
|
+
confirm: rule ? 'Save' : 'Add rule',
|
|
182
|
+
});
|
|
183
|
+
if (wantsDelete) {
|
|
184
|
+
const sure = await dialog({ title: `Delete "${rule.name}"?`, body: h('p', { class: 'fx-dim' }, 'Jobs stop waiting on this rule.'), confirm: 'Delete', tone: 'danger' });
|
|
185
|
+
if (sure && await send(`/api/invoicing/rules/${rule.id}`, 'DELETE')) rules = rules.filter((r) => r.id !== rule.id);
|
|
186
|
+
return draw();
|
|
187
|
+
}
|
|
188
|
+
if (!data) return null;
|
|
189
|
+
const payload = { name: name.value, conditions: editors.map((e) => e.read()), action: { kind: action.value } };
|
|
190
|
+
const body = rule
|
|
191
|
+
? await send(`/api/invoicing/rules/${rule.id}`, 'PUT', payload)
|
|
192
|
+
: await send('/api/invoicing/rules', 'POST', payload);
|
|
193
|
+
if (!body) return edit(rule, payload);
|
|
194
|
+
rules = rule ? rules.map((r) => (r.id === rule.id ? body.rule : r)) : [...rules, body.rule];
|
|
195
|
+
return draw();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const rowOf = (rule) => {
|
|
199
|
+
const toggle = h('button', { class: 'fx-switch', type: 'button', role: 'switch', 'aria-label': rule.name, 'aria-checked': String(rule.enabled), disabled });
|
|
200
|
+
toggle.addEventListener('click', async () => {
|
|
201
|
+
toggle.disabled = true;
|
|
202
|
+
const body = await send(`/api/invoicing/rules/${rule.id}`, 'PUT', { enabled: toggle.getAttribute('aria-checked') !== 'true' });
|
|
203
|
+
toggle.disabled = disabled;
|
|
204
|
+
if (!body) return;
|
|
205
|
+
rules = rules.map((r) => (r.id === rule.id ? body.rule : r));
|
|
206
|
+
toggle.setAttribute('aria-checked', String(body.rule.enabled));
|
|
207
|
+
});
|
|
208
|
+
const controls = [toggle];
|
|
209
|
+
if (!disabled) {
|
|
210
|
+
const open = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', 'aria-label': `Edit ${rule.name}` }, 'Edit');
|
|
211
|
+
open.addEventListener('click', () => edit(rule));
|
|
212
|
+
controls.unshift(open);
|
|
213
|
+
}
|
|
214
|
+
return row(rule.name, ruleSummary(rule, meta), controls);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
function draw() {
|
|
218
|
+
const add = disabled ? null : h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Add rule');
|
|
219
|
+
add?.addEventListener('click', () => edit(null));
|
|
220
|
+
section.replaceChildren(
|
|
221
|
+
h('h2', { class: 'fx-set__title' }, title),
|
|
222
|
+
h('p', { class: 'fx-set__hint', style: { margin: '0 0 4px' } }, 'A ready job waits, or is never drafted, when a rule matches.'),
|
|
223
|
+
...(rules.length ? rules.map(rowOf) : [h('p', { class: 'fx-dim' }, 'No rules. Every ready job is drafted.')]),
|
|
224
|
+
h('div', { class: 'fx-rule__footer' }, add,
|
|
225
|
+
h('a', { class: 'fx-dim', href: DOCS, target: '_blank', rel: 'noopener' }, 'How rules work')));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
(async () => {
|
|
229
|
+
try {
|
|
230
|
+
[meta, { rules }] = await Promise.all([flow('/api/invoicing/rule-fields'), flow('/api/invoicing/rules')]);
|
|
231
|
+
draw();
|
|
232
|
+
} catch (error) {
|
|
233
|
+
section.replaceChildren(h('h2', { class: 'fx-set__title' }, title), notice(error.message, 'danger'));
|
|
234
|
+
}
|
|
235
|
+
})();
|
|
236
|
+
return section;
|
|
237
|
+
}
|