@arcforgelabs/flow 2026.10.32-beta.1 → 2026.10.33

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 (59) hide show
  1. package/README.md +3 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/invoicing.js +69 -26
  5. package/server/app.js +4 -0
  6. package/server/core/automations.js +17 -3
  7. package/server/core/invoices.js +2 -2
  8. package/server/core/invoicing-rules.js +64 -5
  9. package/server/core/job-board.js +15 -4
  10. package/server/core/ledger.js +245 -8
  11. package/server/core/pipeline.js +30 -22
  12. package/server/core/read-before-act.js +96 -0
  13. package/server/core/reconcile.js +350 -0
  14. package/server/core/references.js +99 -0
  15. package/server/core/sending.js +52 -8
  16. package/server/db/migrations/0016_invoice_reference.sql +1 -0
  17. package/server/db/migrations/0017_sync_basis.sql +1 -0
  18. package/server/db/migrations/meta/0016_snapshot.json +3861 -0
  19. package/server/db/migrations/meta/0017_snapshot.json +3867 -0
  20. package/server/db/migrations/meta/_journal.json +14 -0
  21. package/server/db/schema.js +5 -0
  22. package/server/index.js +5 -0
  23. package/server/integrations/port.js +4 -0
  24. package/server/integrations/seazona/act.js +48 -0
  25. package/server/integrations/seazona/feed.js +77 -50
  26. package/server/integrations/seazona/governor.js +28 -2
  27. package/server/integrations/seazona/index.js +69 -3
  28. package/server/integrations/seazona/lanes.js +82 -9
  29. package/server/integrations/seazona/ledger.js +14 -9
  30. package/server/integrations/seazona/migrations/0005_record_reread.sql +1 -0
  31. package/server/integrations/seazona/migrations/0006_sweep_findings.sql +11 -0
  32. package/server/integrations/seazona/migrations/meta/0005_snapshot.json +1329 -0
  33. package/server/integrations/seazona/migrations/meta/0006_snapshot.json +1403 -0
  34. package/server/integrations/seazona/migrations/meta/_journal.json +14 -0
  35. package/server/integrations/seazona/patients.js +30 -0
  36. package/server/integrations/seazona/reconcile.js +40 -0
  37. package/server/integrations/seazona/scheduler.js +32 -4
  38. package/server/integrations/seazona/schema.js +21 -0
  39. package/server/integrations/seazona/settings.js +12 -0
  40. package/server/integrations/seazona/store.js +24 -2
  41. package/server/integrations/seazona/sweep.js +574 -0
  42. package/server/integrations/seazona/tables.js +1 -0
  43. package/server/integrations/xero/client.js +12 -0
  44. package/server/integrations/xero/import.js +9 -3
  45. package/server/integrations/xero/index.js +7 -1
  46. package/server/integrations/xero/migrations/0007_xero_webhooks.sql +30 -0
  47. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  48. package/server/integrations/xero/mirror.js +16 -8
  49. package/server/integrations/xero/routes.js +20 -0
  50. package/server/integrations/xero/settings.js +3 -0
  51. package/server/integrations/xero/webhooks.js +346 -0
  52. package/server/ui/app.js +15 -7
  53. package/server/ui/flow.css +56 -7
  54. package/server/ui/invoice-notes.js +26 -0
  55. package/server/ui/invoicing-settings.js +3 -1
  56. package/server/ui/invoicing.js +231 -68
  57. package/server/ui/job-board.js +2 -1
  58. package/server/ui/jobs.js +19 -1
  59. package/server/ui/routes.js +1 -1
@@ -105,6 +105,15 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
105
105
  border-radius: var(--t-radius-lg); box-shadow: var(--t-shadow);
106
106
  }
107
107
  .fx-panel { overflow: hidden; }
108
+ /* A list that is loading: a thin moving line on top. A list from another
109
+ tab, shown until the new one comes, is dimmed and cannot be clicked. */
110
+ .fx-panel[aria-busy="true"] { position: relative; }
111
+ .fx-panel[aria-busy="true"]::before {
112
+ content: ""; position: absolute; inset: 0 0 auto; height: 2px; z-index: 1;
113
+ background: linear-gradient(90deg, transparent, var(--t-accent), transparent) 0 0 / 200% 100%;
114
+ animation: fx-shimmer 1s linear infinite;
115
+ }
116
+ .fx-panel[data-stale] { opacity: .5; pointer-events: none; transition: opacity .15s; }
108
117
 
109
118
  /* The signed-in app: a standard left sidebar, then the view. */
110
119
  .fx-shell { min-height: 100vh; }
@@ -113,10 +122,17 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
113
122
  display: flex; flex-direction: column; gap: 4px; flex: none;
114
123
  width: 232px; height: 100vh; padding: 16px 12px 12px;
115
124
  background: var(--t-s1); border-right: 1px solid var(--fx-line);
125
+ transition: width .18s ease;
116
126
  }
117
127
  .fx-side__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 0 14px 8px; }
118
128
  .fx-side__head .fx-brand { min-width: 0; font-size: 19px; }
119
129
  .fx-side__head .fx-mark { width: 26px; height: 26px; }
130
+ /* The sidebar button. Open, it shows the sidebar icon beside the brand.
131
+ Folded or narrow, the brand is hidden and the button shows the logo, which
132
+ turns into the sidebar icon on hover or focus (and while the drawer is
133
+ open), so the two never sit side by side. */
134
+ .fx-toggle__mark { display: none; }
135
+ .fx-toggle__mark .fx-mark { display: block; }
120
136
  .fx-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; overflow-y: auto; }
121
137
  .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); }
122
138
  .fx-nav__group:first-child { padding-top: 4px; }
@@ -141,7 +157,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
141
157
  @media (min-width: 821px) {
142
158
  .fx-shell[data-folded] .fx-side { width: 64px; padding-inline: 10px; }
143
159
  .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; }
160
+ .fx-shell[data-folded] .fx-side__head .fx-brand { display: none; }
145
161
  .fx-shell[data-folded] .fx-nav__group { height: 1px; margin: 10px 6px; padding: 0; overflow: hidden; background: var(--fx-line); color: transparent; }
146
162
  .fx-shell[data-folded] :is(.fx-nav a, .fx-nav__report) { justify-content: center; padding: 9px 0; }
147
163
  .fx-shell[data-folded] .fx-nav__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
@@ -659,19 +675,41 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
659
675
  .fx-shell { flex-direction: column; }
660
676
  .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); }
661
677
  .fx-side__head { flex-direction: row-reverse; gap: 6px; padding: 0; }
662
- .fx-side__head .fx-brand span { display: none; }
663
- /* The menu opens as a panel below the bar. */
678
+ .fx-side__head .fx-brand { display: none; }
679
+ /* The menu is a drawer: it slides in from the left, below the bar, over
680
+ a dimmed page. A click on the dimmed page closes it (app.js). */
664
681
  .fx-nav {
665
- display: none; position: absolute; top: 60px; left: 0; right: 0;
666
- max-height: calc(100vh - 60px); padding: 4px 12px 12px;
667
- background: var(--t-s1); border-bottom: 1px solid var(--fx-line); box-shadow: var(--t-shadow);
682
+ position: fixed; top: 60px; bottom: 0; left: 0; z-index: 2;
683
+ width: min(280px, 85vw); padding: 8px 12px 12px;
684
+ background: var(--t-s1); border-right: 1px solid var(--fx-line); box-shadow: var(--t-shadow);
685
+ transform: translateX(-100%); visibility: hidden;
686
+ transition: transform .2s ease, visibility .2s;
687
+ }
688
+ .fx-shell[data-menu-open] .fx-nav { transform: none; visibility: visible; }
689
+ .fx-shell[data-menu-open]::after {
690
+ content: ""; position: fixed; inset: 60px 0 0; z-index: 9;
691
+ background: rgba(0, 0, 0, .32);
668
692
  }
669
- .fx-shell[data-menu-open] .fx-nav { display: flex; }
670
693
  .fx-nav__report { margin-top: 8px; }
671
694
  .fx-side__end { margin-left: auto; padding: 0; border-top: 0; }
672
695
  #user-button { flex: none; }
673
696
  }
674
697
 
698
+ /* Collapsed (folded on wide screens, or narrow): logo at rest, sidebar icon
699
+ on hover, focus or while open. */
700
+ @media (min-width: 821px) {
701
+ .fx-shell[data-folded] .fx-toggle__mark { display: block; }
702
+ .fx-shell[data-folded] .fx-toggle__icon { display: none; }
703
+ .fx-shell[data-folded] #menu-toggle:is(:hover, :focus-visible) .fx-toggle__mark { display: none; }
704
+ .fx-shell[data-folded] #menu-toggle:is(:hover, :focus-visible) .fx-toggle__icon { display: inline-flex; }
705
+ }
706
+ @media (max-width: 820px) {
707
+ .fx-toggle__mark { display: block; }
708
+ .fx-toggle__icon { display: none; }
709
+ #menu-toggle:is(:hover, :focus-visible, [aria-expanded="true"]) .fx-toggle__mark { display: none; }
710
+ #menu-toggle:is(:hover, :focus-visible, [aria-expanded="true"]) .fx-toggle__icon { display: inline-flex; }
711
+ }
712
+
675
713
  /* ── Phones ─────────────────────────────────────────────────────────────── */
676
714
  @media (max-width: 640px) {
677
715
  .fx-main { padding-top: 20px; }
@@ -778,3 +816,14 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
778
816
  color-scheme: light;
779
817
  }
780
818
  [data-mode="dark"] #sentry-feedback { --interactive-filter: brightness(150%); color-scheme: dark; }
819
+
820
+ /* The bar above one invoice: Back, its place in the list, previous and next.
821
+ Quiet: no frame, dim text, round icon buttons. */
822
+ .fx-pager { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; min-height: 32px; }
823
+ .fx-pager__back { justify-self: start; background: none; border: 0; padding: 4px 0; color: var(--t-dim); font: inherit; font-size: 13.5px; cursor: pointer; transition: color 120ms var(--fx-ease); }
824
+ .fx-pager__back:hover, .fx-pager__back:focus-visible { color: var(--t-strong); }
825
+ .fx-pager__count { grid-column: 2; color: var(--t-dim); font-size: 13px; font-variant-numeric: tabular-nums; }
826
+ .fx-pager__steps { grid-column: 3; justify-self: end; display: flex; gap: 4px; }
827
+ .fx-pager__step { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--fx-line-soft); border-radius: 999px; background: transparent; color: var(--t-text); cursor: pointer; transition: background 120ms var(--fx-ease), border-color 120ms var(--fx-ease); }
828
+ .fx-pager__step:hover:not(:disabled) { background: var(--fx-hover); border-color: var(--fx-line); }
829
+ .fx-pager__step:disabled { opacity: 0.35; cursor: default; }
@@ -0,0 +1,26 @@
1
+ // The notes on the Invoices page and on one invoice (invoicing.js). One fact
2
+ // per sentence; they follow the accounting integration's settings. Kept apart
3
+ // so tests can load them without a browser.
4
+
5
+ // What happens to a ready invoice in the accounting system, one fact per
6
+ // sentence. It follows the integration's settings: its connection, whether it
7
+ // takes writes, and the send mode on its page. `what` names the invoices:
8
+ // "each invoice" on the list, "this invoice" on one invoice.
9
+ export function sendModeNote(to, sendMode, what = 'each invoice') {
10
+ if (!to) return 'No accounting system is on. Flow keeps each invoice here only.';
11
+ if (sendMode && !sendMode.connected) return `${to} is not connected. Connect it in Integrations before you send.`;
12
+ if (sendMode && !sendMode.writesAllowed) return `${to} does not take invoices from Flow yet. Turn this on in Integrations, ${to}.`;
13
+ const text = sendMode?.mode === 'approve_and_email'
14
+ ? [`Flow sends ${what} to ${to} as approved.`, `${to} emails it to the customer at once.`]
15
+ : [`Flow sends ${what} to ${to} as a draft.`, `You approve and send it in ${to}.`];
16
+ const others = sendMode?.otherCustomers ?? 0;
17
+ if (others) text.push(`${others} ${others === 1 ? 'customer has' : 'customers have'} a different setting.`);
18
+ return text.join(' ');
19
+ }
20
+
21
+ // The line under a draft's title, one fact per sentence.
22
+ export function draftNote(to, invoice, ready) {
23
+ if (!to) return ready ? 'A person checked it. A saved change keeps it ready.' : 'Flow gives it a number when you issue it.';
24
+ if (!ready) return `Mark it ready, then send it to ${to}.`;
25
+ return sendModeNote(to, invoice?.sendMode, 'this invoice');
26
+ }
@@ -66,7 +66,9 @@ export async function renderInvoicingSettings(main, ui) {
66
66
  host.replaceChildren(...[
67
67
  disabled ? notice('Only an owner or admin can change these.', 'info') : null,
68
68
  section('Drafts',
69
- row('Payment terms', 'Days from invoice to due date.', days('paymentTermsDays', 'Payment terms in days'))),
69
+ row('Payment terms', 'Days from invoice to due date.', days('paymentTermsDays', 'Payment terms in days')),
70
+ row('Reference', 'The Reference on each invoice. Use {practitioner} and {patient}.',
71
+ box('referenceTemplate', 'Reference rule', { placeholder: '{practitioner} - {patient}' }))),
70
72
  lineMappingSection(ui),
71
73
  automationSection(ui, 'invoicing', 'Automations'),
72
74
  rulesSection(ui),