@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.
Files changed (42) hide show
  1. package/README.md +5 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +2 -1
  4. package/server/api/invoicing.js +231 -20
  5. package/server/app.js +28 -2
  6. package/server/core/automations.js +140 -0
  7. package/server/core/invoice-rules.js +224 -0
  8. package/server/core/invoices.js +4 -1
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/ledger.js +45 -12
  11. package/server/core/monitoring.js +34 -0
  12. package/server/core/pipeline.js +8 -1
  13. package/server/core/sending.js +282 -0
  14. package/server/db/migrations/0010_invoicing_rules.sql +51 -0
  15. package/server/db/migrations/0011_invoice_rules.sql +21 -0
  16. package/server/db/migrations/0012_invoice_sending.sql +9 -0
  17. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  18. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  19. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  20. package/server/db/migrations/meta/_journal.json +21 -0
  21. package/server/db/schema.js +115 -0
  22. package/server/index.js +12 -4
  23. package/server/integrations/adapters.js +2 -1
  24. package/server/integrations/port.js +5 -0
  25. package/server/integrations/seazona/ledger.js +2 -0
  26. package/server/integrations/sentry/index.js +105 -0
  27. package/server/integrations/xero/client.js +5 -0
  28. package/server/integrations/xero/drafts.js +123 -16
  29. package/server/integrations/xero/index.js +11 -1
  30. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
  31. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  32. package/server/ui/app-page.js +2 -1
  33. package/server/ui/app.js +14 -1
  34. package/server/ui/automations.js +96 -0
  35. package/server/ui/flow.css +88 -0
  36. package/server/ui/invoice-rules.js +237 -0
  37. package/server/ui/invoicing-settings.js +108 -0
  38. package/server/ui/invoicing.js +166 -20
  39. package/server/ui/jobs.js +19 -0
  40. package/server/ui/logos/sentry.svg +1 -0
  41. package/server/ui/monitoring.js +53 -0
  42. 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'));
@@ -36,6 +36,13 @@
36
36
  "when": 1792388000000,
37
37
  "tag": "0004_xero_mirror",
38
38
  "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "7",
43
+ "when": 1792406000000,
44
+ "tag": "0005_xero_send_modes",
45
+ "breakpoints": true
39
46
  }
40
47
  ]
41
48
  }
@@ -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
+ }
@@ -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
+ }