@arcforgelabs/flow 2026.10.26 → 2026.10.29

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 (63) hide show
  1. package/README.md +6 -0
  2. package/npm-shrinkwrap.json +627 -2
  3. package/package.json +3 -2
  4. package/server/api/invoicing.js +313 -46
  5. package/server/app.js +30 -2
  6. package/server/core/automations.js +154 -0
  7. package/server/core/invoice-rules.js +226 -0
  8. package/server/core/invoices.js +8 -3
  9. package/server/core/invoicing-rules.js +255 -0
  10. package/server/core/job-board.js +130 -0
  11. package/server/core/ledger.js +57 -17
  12. package/server/core/line-mapping.js +337 -0
  13. package/server/core/monitoring.js +34 -0
  14. package/server/core/pipeline.js +16 -3
  15. package/server/core/sending.js +283 -0
  16. package/server/db/migrate-all.js +26 -0
  17. package/server/db/migrations/0010_invoicing_rules.sql +72 -0
  18. package/server/db/migrations/0011_invoice_rules.sql +27 -0
  19. package/server/db/migrations/0012_invoice_sending.sql +18 -0
  20. package/server/db/migrations/0013_line_mappings.sql +35 -0
  21. package/server/db/migrations/meta/0010_snapshot.json +3158 -0
  22. package/server/db/migrations/meta/0011_snapshot.json +3314 -0
  23. package/server/db/migrations/meta/0012_snapshot.json +3380 -0
  24. package/server/db/migrations/meta/0013_snapshot.json +3568 -0
  25. package/server/db/migrations/meta/_journal.json +28 -0
  26. package/server/db/schema.js +158 -0
  27. package/server/import/at-start.js +64 -2
  28. package/server/index.js +17 -5
  29. package/server/integrations/adapters.js +2 -1
  30. package/server/integrations/port.js +6 -0
  31. package/server/integrations/seazona/feed.js +62 -4
  32. package/server/integrations/seazona/index.js +7 -1
  33. package/server/integrations/seazona/lanes.js +4 -1
  34. package/server/integrations/seazona/ledger.js +2 -0
  35. package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
  36. package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
  37. package/server/integrations/sentry/index.js +105 -0
  38. package/server/integrations/xero/client.js +5 -0
  39. package/server/integrations/xero/drafts.js +127 -17
  40. package/server/integrations/xero/index.js +11 -1
  41. package/server/integrations/xero/lookup.js +6 -2
  42. package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
  43. package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
  44. package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
  45. package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
  46. package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
  47. package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
  48. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  49. package/server/ui/app-page.js +7 -4
  50. package/server/ui/app.js +122 -6
  51. package/server/ui/automations.js +96 -0
  52. package/server/ui/clerk-appearance.js +21 -0
  53. package/server/ui/flow.css +243 -30
  54. package/server/ui/invoice-rules.js +237 -0
  55. package/server/ui/invoicing-settings.js +110 -0
  56. package/server/ui/invoicing.js +166 -20
  57. package/server/ui/job-board.js +155 -0
  58. package/server/ui/jobs.js +232 -84
  59. package/server/ui/line-mappings.js +60 -0
  60. package/server/ui/logos/sentry.svg +1 -0
  61. package/server/ui/monitoring.js +61 -0
  62. package/server/ui/routes.js +2 -2
  63. package/server/ui/search.js +145 -0
@@ -114,23 +114,42 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
114
114
  width: 232px; height: 100vh; padding: 16px 12px 12px;
115
115
  background: var(--t-s1); border-right: 1px solid var(--fx-line);
116
116
  }
117
- .fx-side > .fx-brand { padding: 4px 8px 18px; font-size: 19px; }
118
- .fx-side > .fx-brand .fx-mark { width: 26px; height: 26px; }
117
+ .fx-side__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 0 14px 8px; }
118
+ .fx-side__head .fx-brand { min-width: 0; font-size: 19px; }
119
+ .fx-side__head .fx-mark { width: 26px; height: 26px; }
119
120
  .fx-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; overflow-y: auto; }
120
121
  .fx-nav__group { padding: 16px 10px 6px; color: var(--t-dim); font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body); letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case); }
121
122
  .fx-nav__group:first-child { padding-top: 4px; }
122
- .fx-nav a {
123
+ .fx-nav a, .fx-nav__report {
123
124
  display: flex; align-items: center; gap: 10px;
124
125
  padding: 8px 10px; border-radius: var(--t-radius);
125
126
  color: var(--t-text); font-weight: 500; font-size: 14px; text-decoration: none; white-space: nowrap;
126
127
  }
127
- .fx-nav a:hover { color: var(--t-strong); background: var(--fx-hover); }
128
+ .fx-nav a:hover, .fx-nav__report:hover { color: var(--t-strong); background: var(--fx-hover); }
129
+ .fx-nav__report { margin-top: auto; border: 0; background: none; font: inherit; font-weight: 500; font-size: 14px; text-align: left; cursor: pointer; }
130
+ .fx-nav__report[hidden] { display: none; }
128
131
  .fx-nav a[aria-current="page"] { color: var(--t-accent); background: var(--t-accent-bg); font-weight: 650; box-shadow: inset 2px 0 0 var(--t-brand); }
129
132
  .fx-nav__count { margin-left: auto; color: var(--t-dim); font-size: 12px; font-weight: 700; }
130
133
  .fx-ico { display: inline-flex; flex: none; }
131
134
  .fx-ico svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
132
135
  .fx-side__end { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 6px 0; border-top: 1px solid var(--fx-line); }
133
136
  .fx-side__user { display: flex; min-width: 32px; min-height: 32px; align-items: center; }
137
+ /* Clerk replaces the mount's classes with its root box's, so this uses the id. */
138
+ #user-button { flex: 1 1 auto; min-width: 0; }
139
+ /* Folded (wide screens only): the sidebar keeps its icons, the menu button
140
+ and the account photo; each page's name is still read out. */
141
+ @media (min-width: 821px) {
142
+ .fx-shell[data-folded] .fx-side { width: 64px; padding-inline: 10px; }
143
+ .fx-shell[data-folded] .fx-side__head { flex-direction: column; gap: 10px; padding: 0 0 14px; }
144
+ .fx-shell[data-folded] .fx-brand span { display: none; }
145
+ .fx-shell[data-folded] .fx-nav__group { height: 1px; margin: 10px 6px; padding: 0; overflow: hidden; background: var(--fx-line); color: transparent; }
146
+ .fx-shell[data-folded] :is(.fx-nav a, .fx-nav__report) { justify-content: center; padding: 9px 0; }
147
+ .fx-shell[data-folded] .fx-nav__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
148
+ .fx-shell[data-folded] .fx-side__end { flex-direction: column; padding: 12px 0 0; }
149
+ .fx-shell[data-folded] #user-button { flex: none; }
150
+ .fx-shell[data-folded] #user-button .cl-userButtonTrigger { width: auto; padding: 0; border-radius: 50%; background: transparent; box-shadow: none; }
151
+ .fx-shell[data-folded] #user-button .cl-userButtonOuterIdentifier { display: none; }
152
+ }
134
153
  .fx-main { flex: 1; min-width: 0; max-width: 1180px; margin: 0 auto; padding: 32px clamp(16px, 4vw, 48px) 64px; display: grid; gap: 20px; align-content: start; }
135
154
  .fx-main:focus { outline: none; box-shadow: none; }
136
155
 
@@ -295,6 +314,8 @@ a.fx-strong { text-decoration: none; }
295
314
  a.fx-strong:hover { text-decoration: underline; }
296
315
  .fx-toolbar { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--fx-line); }
297
316
  .fx-tabs { display: flex; gap: 2px; }
317
+ .fx-tabs button { flex: none; }
318
+ @media (max-width: 640px) { .fx-tabs { max-width: 100%; overflow-x: auto; scrollbar-width: none; } }
298
319
  .fx-tabs button {
299
320
  display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; margin-bottom: -1px;
300
321
  border: 0; border-bottom: 2px solid transparent; background: transparent; cursor: pointer;
@@ -303,6 +324,15 @@ a.fx-strong:hover { text-decoration: underline; }
303
324
  .fx-tabs button:hover { color: var(--t-strong); }
304
325
  .fx-tabs button[aria-selected="true"] { color: var(--t-strong); border-bottom-color: var(--t-brand); }
305
326
  .fx-tabs__count { padding: 0 7px; border-radius: 999px; background: var(--fx-hover); color: var(--t-dim); font-size: 12px; line-height: 20px; }
327
+ .fx-tabs button[data-tone="danger"] .fx-tabs__count { background: var(--t-danger-bg); color: var(--t-danger); }
328
+ /* Invoices: the Send all ready bar above the Ready list. */
329
+ .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; }
330
+ .fx-sendbar[hidden] { display: none; }
331
+ .fx-sendbar p { margin: 0; }
332
+ .fx-problem { color: var(--t-danger); }
333
+ .fx-sendbar__actions { display: flex; flex-wrap: wrap; gap: 8px; }
334
+ .fx-tick-cell { width: 40px; }
335
+ .fx-tick { width: 16px; height: 16px; accent-color: var(--t-brand); cursor: pointer; }
306
336
  .fx-search { width: min(320px, 100%); margin-bottom: 8px; }
307
337
  .fx-table--rows tbody tr { cursor: pointer; }
308
338
  .fx-table th.fx-num { text-align: right; }
@@ -373,25 +403,107 @@ a.fx-strong:hover { text-decoration: underline; }
373
403
  .fx-toolbar--plain .fx-search { margin-bottom: 0; }
374
404
  .fx-empty .fx-btn { margin-top: 12px; }
375
405
  .fx-panel.fx-empty { display: grid; justify-items: center; }
376
- .fx-board { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(240px, 1fr); gap: 12px; overflow-x: auto; padding-bottom: 8px; align-items: start; }
377
- .fx-board__col { display: grid; gap: 8px; align-content: start; min-height: 240px; padding: 10px; border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); background: var(--t-s1); }
378
- .fx-board__head { display: flex; align-items: center; gap: 8px; padding: 2px 4px 4px; font-size: 14px; }
379
- .fx-board__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--t-accent); flex: none; }
380
- .fx-board__dot[data-tone="warn"] { background: var(--t-warn); }
381
- .fx-board__dot[data-tone="success"] { background: var(--t-success); }
382
- .fx-board__dot[data-tone="mute"] { background: var(--t-dim); }
383
- .fx-board__count { padding: 0 7px; border-radius: 999px; background: var(--fx-hover); color: var(--t-dim); font-size: 12px; font-weight: 700; line-height: 20px; }
384
- .fx-board__sum { margin-left: auto; color: var(--t-dim); font-size: 12.5px; font-variant-numeric: tabular-nums; }
385
- .fx-board__cards { display: grid; gap: 8px; }
386
- .fx-board__none { padding: 12px 4px; color: var(--t-dim); font-size: 13px; }
387
- .fx-job { display: grid; gap: 4px; padding: 10px 12px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); box-shadow: var(--t-shadow); font-size: 13.5px; }
388
- .fx-job--flag { border-left: 3px solid var(--t-warn); }
389
- .fx-job__top { display: flex; align-items: baseline; gap: 8px; }
390
- .fx-job__amount { margin-left: auto; color: var(--t-strong); font-weight: 600; font-family: var(--t-data, var(--t-body)); font-variant-numeric: tabular-nums; }
391
- .fx-job__client { color: var(--t-text); }
392
- .fx-job__meta { display: flex; flex-wrap: wrap; gap: 4px 10px; color: var(--t-dim); font-size: 12.5px; }
393
- .fx-job__why { color: var(--t-warn); font-size: 12.5px; }
394
- .fx-job .fx-btn { justify-self: start; margin-top: 4px; }
406
+ /* The board never scrolls sideways: its columns (one per work system status)
407
+ wrap onto more rows when the page is narrow, and stack on a phone. A long
408
+ column scrolls inside itself. */
409
+ .fx-board { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr)); gap: 10px; align-items: start; }
410
+ .fx-board__col { display: grid; gap: 8px; align-content: start; min-width: 0; padding: 10px 8px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
411
+ .fx-board__head { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 2px 2px 4px; }
412
+ .fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
413
+ .fx-board__cards { display: grid; gap: 8px; max-height: 70vh; overflow-y: auto; }
414
+ .fx-board__none { padding: 8px 4px; color: var(--t-dim); font-size: 13px; }
415
+ .fx-table--jobs tbody tr { cursor: pointer; }
416
+
417
+ /* A work system status as a coloured label: board columns and the job page.
418
+ The colour follows where the status sits in the work (job-board.js,
419
+ statusRank): new, in work, checking and complete, out of the lab, cancelled. */
420
+ .fx-stage {
421
+ --stage: var(--t-accent);
422
+ display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-width: 0; padding: 3px 8px 3px 7px;
423
+ border: 1px solid color-mix(in srgb, var(--stage) 30%, transparent); border-radius: 4px;
424
+ background: color-mix(in srgb, var(--stage) 12%, transparent); color: var(--stage);
425
+ font-size: 11px; font-weight: 700; line-height: 1.3; letter-spacing: .04em; text-transform: uppercase;
426
+ }
427
+ .fx-stage::before { content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
428
+ .fx-stage__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
429
+ .fx-stage__count { opacity: .75; font-variant-numeric: tabular-nums; }
430
+ .fx-stage[data-rank="0"] { --stage: var(--t-mute); }
431
+ .fx-stage[data-rank="3"], .fx-stage[data-rank="4"] { --stage: color-mix(in srgb, var(--t-accent) 45%, var(--t-success)); }
432
+ .fx-stage[data-rank="5"], .fx-stage[data-rank="6"] { --stage: var(--t-success); }
433
+ .fx-stage[data-rank="7"] { --stage: var(--t-dim); }
434
+
435
+ /* ── Job card ───────────────────────────────────────────────────────────────
436
+ One card on the board ("Task card"; jobs.js, jobCard): client,
437
+ practitioner, one date, total, and a small warning mark when the job has a
438
+ problem. The card design may be swapped: change this block and jobCard
439
+ together. */
440
+ .fx-job { position: relative; display: grid; min-width: 0; padding: 11px 12px 9px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); box-shadow: var(--t-shadow); font-size: 13px; cursor: pointer; }
441
+ .fx-job:hover { border-color: var(--t-brd-hi, var(--fx-line)); }
442
+ .fx-job:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 2px; }
443
+ .fx-job__client { display: -webkit-box; overflow: hidden; color: var(--t-strong); font-size: 14px; font-weight: 600; line-height: 1.3; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
444
+ .fx-job__who { display: flex; align-items: center; gap: 6px; min-width: 0; margin-top: 5px; color: var(--t-dim); font-size: 12.5px; }
445
+ .fx-job__who span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
446
+ .fx-job__who svg, .fx-job__date svg { flex: none; color: var(--t-mute); }
447
+ .fx-job__foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 12px; }
448
+ .fx-job__date { display: inline-flex; align-items: center; gap: 5px; min-width: 0; overflow: hidden; white-space: nowrap; }
449
+ .fx-job__date--late, .fx-job__date--late svg { color: var(--t-danger); }
450
+ .fx-job__space { flex: 1; }
451
+ .fx-job__amount { color: var(--t-text); font-weight: 600; font-family: var(--t-data, var(--t-body)); font-variant-numeric: tabular-nums; white-space: nowrap; }
452
+ .fx-job__problem { display: inline-grid; place-items: center; flex: none; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: var(--t-warn); cursor: pointer; }
453
+ .fx-job__problem:hover, .fx-job__problem:focus-visible { background: var(--t-warn-bg); }
454
+ .fx-job__cell { display: inline-flex; align-items: center; gap: 6px; }
455
+
456
+ /* ── Job page ───────────────────────────────────────────────────────────────
457
+ A job opened from the board: a large panel over it, like a task page, with
458
+ the activity at the side. It stacks on a phone. */
459
+ .fx-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--t-dim); overflow-wrap: anywhere; }
460
+ .fx-dialog.fx-task { width: min(1040px, calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow-y: auto; }
461
+ .fx-task__grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; }
462
+ .fx-task__main { min-width: 0; padding: 20px 28px 28px; }
463
+ .fx-task__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
464
+ .fx-task__crumb { display: flex; flex-wrap: wrap; gap: 6px; color: var(--t-dim); font-size: 12.5px; }
465
+ .fx-task__slash { color: var(--t-mute); }
466
+ .fx-task__title { margin: 6px 0 12px; color: var(--t-strong); font: var(--t-display-weight) 30px/1.15 var(--t-display); overflow-wrap: anywhere; }
467
+ .fx-task__stage { margin-bottom: 18px; }
468
+ .fx-task__fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 32px; margin: 0; }
469
+ .fx-task__fields > div { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 12px; align-items: center; min-width: 0; padding: 8px 0; border-bottom: 1px solid var(--fx-line-soft); }
470
+ .fx-task__fields dt { color: var(--t-dim); font-size: 12.5px; }
471
+ .fx-task__fields dd { min-width: 0; margin: 0; color: var(--t-strong); font-size: 13.5px; overflow-wrap: anywhere; }
472
+ .fx-task__late { color: var(--t-danger) !important; }
473
+ .fx-task__link { color: var(--t-accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--t-accent) 40%, transparent); }
474
+ .fx-task__link:hover { border-bottom-color: currentColor; }
475
+ .fx-task__fix { scroll-margin-top: 16px; margin-top: 22px; border: 1px solid var(--t-warn-brd, var(--t-warn)); border-radius: var(--t-radius); background: var(--t-warn-bg); }
476
+ .fx-task__fix h3, .fx-task__lines h3, .fx-task__activity h3 { margin: 0; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
477
+ .fx-task__fix h3 { padding: 12px 14px 4px; color: var(--t-warn); }
478
+ .fx-task__fixrow { padding: 9px 14px; border-top: 1px solid var(--fx-line-soft); color: var(--t-strong); font-size: 13.5px; }
479
+ .fx-task__fixrow:first-of-type { border-top: 0; }
480
+ .fx-task__lines { margin-top: 24px; }
481
+ .fx-task__lines h3 { margin-bottom: 8px; }
482
+ .fx-task__lines table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; }
483
+ .fx-task__lines th { padding: 6px 8px 6px 0; border-bottom: 1px solid var(--fx-line); color: var(--t-mute); font-size: 12px; font-weight: 500; text-align: left; }
484
+ .fx-task__lines td { padding: 9px 8px 9px 0; border-bottom: 1px solid var(--fx-line-soft); vertical-align: top; overflow-wrap: anywhere; }
485
+ .fx-task__lines .fx-num { padding-right: 0; text-align: right; }
486
+ .fx-task__lines tfoot td { border-bottom: 0; color: var(--t-strong); font-weight: 600; }
487
+ .fx-task__col-code { width: 130px; }
488
+ .fx-task__col-amount { width: 96px; }
489
+ .fx-task__miss { color: var(--t-warn); }
490
+ .fx-task__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; }
491
+ .fx-task__actions:empty { display: none; }
492
+ .fx-task__activity { min-width: 0; padding: 24px 20px; border-left: 1px solid var(--fx-line); background: var(--t-s1); }
493
+ .fx-task__activity h3 { margin-bottom: 14px; }
494
+ .fx-task__activity ol { margin: 0; padding: 0; list-style: none; }
495
+ .fx-task__activity li { position: relative; padding: 0 0 16px 18px; color: var(--t-text); font-size: 13px; }
496
+ .fx-task__activity li::before { content: ""; position: absolute; left: 3px; top: 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--t-mute); }
497
+ .fx-task__activity li::after { content: ""; position: absolute; left: 5.5px; top: 16px; bottom: 0; width: 1px; background: var(--fx-line); }
498
+ .fx-task__activity li:last-child::after { display: none; }
499
+ .fx-task__activity time { display: block; margin-top: 1px; color: var(--t-mute); font-size: 11.5px; }
500
+ @media (max-width: 760px) {
501
+ .fx-dialog.fx-task { width: 100vw; max-width: 100vw; height: 100vh; max-height: 100vh; margin: 0; border-radius: 0; }
502
+ .fx-task__grid { grid-template-columns: minmax(0, 1fr); }
503
+ .fx-task__main { padding: 16px; }
504
+ .fx-task__fields { grid-template-columns: minmax(0, 1fr); }
505
+ .fx-task__activity { padding: 16px; border-top: 1px solid var(--fx-line); border-left: 0; }
506
+ }
395
507
 
396
508
  /* ── Dialog ─────────────────────────────────────────────────────────────── */
397
509
  .fx-dialog {
@@ -405,6 +517,25 @@ a.fx-strong:hover { text-decoration: underline; }
405
517
  .fx-dialog__title { color: var(--t-strong); font: var(--t-display-weight) 21px/1.2 var(--t-display); }
406
518
  .fx-dialog__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
407
519
 
520
+ /* Search (search.js): a box near the top, results grouped under it. */
521
+ .fx-search-dialog { width: min(600px, calc(100vw - 32px)); margin-top: 12vh; }
522
+ .fx-search-dialog__bar { display: flex; align-items: center; gap: 10px; padding: 10px 12px 10px 16px; border-bottom: 1px solid var(--fx-line); color: var(--t-dim); }
523
+ .fx-search-dialog__input { flex: 1; min-width: 0; padding: 6px 0; border: 0; background: transparent; color: var(--t-strong); font: 500 16px/1.4 var(--t-body); }
524
+ .fx-search-dialog__input:focus { outline: none; box-shadow: none; }
525
+ .fx-search-dialog__input::-webkit-search-cancel-button { display: none; }
526
+ .fx-search-dialog__results { max-height: min(60vh, 460px); overflow-y: auto; padding: 6px; }
527
+ .fx-search-dialog__group + .fx-search-dialog__group { margin-top: 6px; }
528
+ .fx-search-dialog__label { padding: 8px 10px 4px; color: var(--t-dim); font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body); letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case); }
529
+ .fx-search-dialog__option { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: var(--t-radius); color: var(--t-text); text-decoration: none; }
530
+ .fx-search-dialog__option:hover { background: var(--fx-hover); }
531
+ .fx-search-dialog__option[aria-selected="true"] { color: var(--t-accent); background: var(--t-accent-bg); }
532
+ .fx-search-dialog__text { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
533
+ .fx-search-dialog__title { flex: none; color: inherit; font-weight: 600; font-size: 14px; }
534
+ .fx-search-dialog__detail { overflow: hidden; color: var(--t-dim); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
535
+ /* --t-dim is only checked on plain surfaces; on the accent tint, --t-text passes. */
536
+ .fx-search-dialog__option[aria-selected="true"] .fx-search-dialog__detail { color: var(--t-text); }
537
+ .fx-search-dialog__empty { padding: 18px 10px; color: var(--t-dim); text-align: center; }
538
+
408
539
  /* ── Sign-in ────────────────────────────────────────────────────────────── */
409
540
  .fx-auth { min-height: 100vh; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); }
410
541
  .fx-auth__brand {
@@ -506,13 +637,18 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
506
637
  @media (max-width: 820px) {
507
638
  .fx-shell { flex-direction: column; }
508
639
  .fx-side { flex-direction: row; align-items: center; width: auto; height: 60px; padding: 0 12px; border-right: 0; border-bottom: 1px solid var(--fx-line); }
509
- .fx-side > .fx-brand { padding: 0; }
510
- .fx-side > .fx-brand span { display: none; }
511
- .fx-nav { flex-direction: row; overflow-x: auto; overflow-y: hidden; }
512
- .fx-nav__group { display: none; }
513
- .fx-nav a { padding: 7px 9px; }
514
- .fx-nav a .fx-ico { display: none; }
515
- .fx-side__end { padding: 0; border-top: 0; }
640
+ .fx-side__head { flex-direction: row-reverse; gap: 6px; padding: 0; }
641
+ .fx-side__head .fx-brand span { display: none; }
642
+ /* The menu opens as a panel below the bar. */
643
+ .fx-nav {
644
+ display: none; position: absolute; top: 60px; left: 0; right: 0;
645
+ max-height: calc(100vh - 60px); padding: 4px 12px 12px;
646
+ background: var(--t-s1); border-bottom: 1px solid var(--fx-line); box-shadow: var(--t-shadow);
647
+ }
648
+ .fx-shell[data-menu-open] .fx-nav { display: flex; }
649
+ .fx-nav__report { margin-top: 8px; }
650
+ .fx-side__end { margin-left: auto; padding: 0; border-top: 0; }
651
+ #user-button { flex: none; }
516
652
  }
517
653
 
518
654
  /* ── Phones ─────────────────────────────────────────────────────────────── */
@@ -536,3 +672,80 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
536
672
  @media (prefers-reduced-motion: reduce) {
537
673
  *, *::before, *::after { animation: none !important; transition: none !important; }
538
674
  }
675
+
676
+ /* ── Settings rows ───────────────────────────────────────────────────────
677
+ One row for each setting: its name, one short line under it, and the
678
+ control on the right. */
679
+ .fx-set { display: grid; gap: 4px; }
680
+ .fx-set__title { margin: 8px 0 4px; color: var(--t-strong); font: 600 15px/1.3 var(--t-body); }
681
+ .fx-set__row { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 12px 0; }
682
+ .fx-set__row + .fx-set__row { border-top: 1px solid var(--fx-line); }
683
+ .fx-set__text { display: grid; gap: 2px; min-width: 0; }
684
+ .fx-set__name { color: var(--t-text); font: 500 14px/1.35 var(--t-body); }
685
+ .fx-set__hint { color: var(--t-dim); font-size: 13px; line-height: 1.35; }
686
+ .fx-set__control { flex: none; display: flex; justify-content: flex-end; }
687
+ .fx-set__control .fx-input { width: 220px; }
688
+ .fx-set__control .fx-input[type="number"] { width: 96px; }
689
+ .fx-set__control .fx-select { min-width: 220px; }
690
+ .fx-set + .fx-set { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--fx-line); }
691
+ /* Invoice rules (invoice-rules.js): a row's Edit button and switch, and the
692
+ rule dialog's condition lines. */
693
+ .fx-rule__controls { align-items: center; gap: 10px; }
694
+ .fx-rule__footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
695
+ .fx-dialog:has(.fx-rule__dialog) { width: min(680px, calc(100vw - 32px)); }
696
+ .fx-rule__dialog .fx-btn--sm { justify-self: start; }
697
+ .fx-rule__conditions { display: grid; gap: 8px; }
698
+ .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; }
699
+ .fx-rule__condition + .fx-rule__condition { padding-top: 8px; border-top: 1px solid var(--fx-line); }
700
+ .fx-rule__condition .fx-select, .fx-rule__condition .fx-input { width: 100%; min-width: 0; }
701
+ .fx-rule__value { display: flex; gap: 6px; align-items: center; min-width: 0; }
702
+ .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%; }
703
+ .fx-rule__delete { justify-self: start; color: var(--t-danger); }
704
+ @media (max-width: 640px) {
705
+ .fx-rule__condition { grid-template-columns: minmax(0, 1fr); }
706
+ }
707
+ @media (max-width: 560px) {
708
+ .fx-set__row { gap: 12px; }
709
+ .fx-set__control .fx-input { width: 140px; }
710
+ .fx-set__control .fx-input[type="number"] { width: 72px; }
711
+ .fx-set__control .fx-select { min-width: 140px; }
712
+ }
713
+ .fx-multi { position: relative; width: 220px; max-width: 100%; }
714
+ .fx-multi__summary { display: flex; align-items: center; list-style: none; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
715
+ @media (max-width: 600px) { .fx-multi { width: 150px; } }
716
+ .fx-multi__summary::-webkit-details-marker { display: none; }
717
+ .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); }
718
+ .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; }
719
+ .fx-multi__option:hover { background: var(--fx-line-soft); }
720
+ .fx-switch {
721
+ position: relative; width: 40px; height: 22px; padding: 0; flex: none; cursor: pointer;
722
+ border: 0; border-radius: 999px; background: var(--t-control);
723
+ }
724
+ .fx-switch::after {
725
+ content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px;
726
+ border-radius: 50%; background: var(--t-paper); transition: transform 0.15s;
727
+ }
728
+ .fx-switch[aria-checked="true"] { background: var(--t-primary); }
729
+ .fx-switch[aria-checked="true"]::after { transform: translateX(18px); }
730
+ .fx-switch:focus-visible { outline: none; box-shadow: var(--fx-focus); }
731
+ .fx-switch:disabled { cursor: default; opacity: 0.6; }
732
+
733
+ /* ── Sentry's report form (monitoring.js) ──────────────────────────────────
734
+ The form lives in a shadow root and reads these variables from its host.
735
+ Rules here win over the form's own defaults, so it takes each person's
736
+ theme and mode, and follows a change at once. */
737
+ #sentry-feedback {
738
+ --font-family: var(--t-body);
739
+ --foreground: var(--t-text);
740
+ --background: var(--t-paper);
741
+ --accent-foreground: var(--t-on-primary);
742
+ --accent-background: var(--t-primary);
743
+ --success-color: var(--t-success);
744
+ --error-color: var(--t-danger);
745
+ --border: 1px solid var(--t-control);
746
+ --box-shadow: var(--t-shadow);
747
+ --outline: 2px solid var(--t-primary);
748
+ --interactive-filter: brightness(95%);
749
+ color-scheme: light;
750
+ }
751
+ [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
+ }