@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
@@ -0,0 +1,145 @@
1
+ // Search across Flow, from the button at the foot of the sidebar or with
2
+ // Ctrl+K (⌘K on a Mac): the pages, then invoices by number, client or
3
+ // reference, then jobs by case, client or practitioner. Up and down move
4
+ // through the results, Enter opens one, Escape closes. Text from the server
5
+ // is only ever set as text.
6
+
7
+ const LIMIT = 6;
8
+
9
+ const lower = (value) => String(value ?? '').toLowerCase();
10
+
11
+ /**
12
+ * `views` is [{ name, label, icon }] for the pages this person may open;
13
+ * `icon(name)` draws a sidebar icon; `go(path)` opens a path in place.
14
+ */
15
+ export function openSearch({ h, flow, go, icon, views }) {
16
+ if (document.querySelector('.fx-search-dialog')) return;
17
+ const input = h('input', {
18
+ class: 'fx-search-dialog__input', type: 'search', placeholder: 'Search pages, invoices and jobs',
19
+ 'aria-label': 'Search Flow', 'aria-controls': 'search-results', autocomplete: 'off',
20
+ });
21
+ const results = h('div', { class: 'fx-search-dialog__results', id: 'search-results', role: 'listbox', 'aria-label': 'Results' });
22
+ const element = h('dialog', { class: 'fx-dialog fx-search-dialog', 'aria-label': 'Search' },
23
+ h('div', { class: 'fx-search-dialog__bar' }, icon('search'), input,
24
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => element.close() }, 'Close')),
25
+ results);
26
+
27
+ // Jobs come as one list (GET /api/invoicing/jobs), loaded once per opening
28
+ // and filtered here, the same way the Jobs page filters them.
29
+ let jobs = null;
30
+ const jobsReady = flow('/api/invoicing/jobs').then((body) => { jobs = body.jobs ?? []; }).catch(() => { jobs = []; });
31
+ let asked = 0;
32
+ let timer = null;
33
+ let options = [];
34
+ let active = -1;
35
+
36
+ function option({ path, title, detail, kind }) {
37
+ const link = h('a', { class: 'fx-search-dialog__option', href: path, role: 'option', 'aria-selected': 'false', tabindex: '-1' },
38
+ icon(kind),
39
+ h('span', { class: 'fx-search-dialog__text' },
40
+ h('span', { class: 'fx-search-dialog__title' }, title),
41
+ detail ? h('span', { class: 'fx-search-dialog__detail' }, detail) : null));
42
+ link.addEventListener('click', (event) => {
43
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
44
+ event.preventDefault();
45
+ open(path);
46
+ });
47
+ return link;
48
+ }
49
+
50
+ function open(path) {
51
+ element.close();
52
+ go(path);
53
+ }
54
+
55
+ function select(index) {
56
+ options.forEach((node, i) => node.setAttribute('aria-selected', String(i === index)));
57
+ active = index;
58
+ const node = options[index];
59
+ if (node) {
60
+ input.setAttribute('aria-activedescendant', node.id);
61
+ node.scrollIntoView({ block: 'nearest' });
62
+ } else {
63
+ input.removeAttribute('aria-activedescendant');
64
+ }
65
+ }
66
+
67
+ function draw(groups, waiting) {
68
+ const sections = groups.filter((group) => group.items.length).map((group) => h('div', { class: 'fx-search-dialog__group', role: 'group', 'aria-label': group.label },
69
+ h('div', { class: 'fx-search-dialog__label', 'aria-hidden': 'true' }, group.label),
70
+ group.items.map(option)));
71
+ if (!sections.length) sections.push(h('p', { class: 'fx-search-dialog__empty' }, waiting ? 'Searching…' : 'Nothing found.'));
72
+ results.replaceChildren(...sections);
73
+ options = [...results.querySelectorAll('[role="option"]')];
74
+ options.forEach((node, i) => { node.id = `search-option-${i}`; });
75
+ select(options.length ? 0 : -1);
76
+ }
77
+
78
+ const pages = (q) => views.filter((view) => !q || lower(view.label).includes(q))
79
+ .map((view) => ({ path: `/${view.name}`, title: view.label, kind: view.icon }));
80
+
81
+ const matchingJobs = (q) => (jobs ?? [])
82
+ .filter((job) => [job.caseRef, job.sourceJobRef, job.contactName, job.practitioner, job.invoiceNumber]
83
+ .some((value) => value && lower(value).includes(q)))
84
+ .slice(0, LIMIT)
85
+ .map((job) => ({
86
+ // A job with an invoice opens that invoice; any other opens the Jobs page, filtered to it.
87
+ path: job.invoiceId ? `/invoices/${job.invoiceId}` : `/jobs?q=${encodeURIComponent(job.caseRef || job.sourceJobRef || '')}`,
88
+ // A person reads the client, not the case number.
89
+ title: job.contactName || job.practitioner || 'Job',
90
+ detail: [job.contactName ? job.practitioner : null, job.invoiceNumber].filter(Boolean).join(' · '),
91
+ kind: 'jobs',
92
+ }));
93
+
94
+ async function search() {
95
+ const q = lower(input.value.trim());
96
+ const turn = ++asked;
97
+ if (!q) {
98
+ draw([{ label: 'Pages', items: pages('') }]);
99
+ return;
100
+ }
101
+ draw([{ label: 'Pages', items: pages(q) }], true);
102
+ const [invoices] = await Promise.all([
103
+ flow(`/api/invoicing/invoices?${new URLSearchParams({ q, limit: String(LIMIT) })}`)
104
+ .then((body) => body.invoices ?? []).catch(() => []),
105
+ jobsReady,
106
+ ]);
107
+ if (turn !== asked) return;
108
+ draw([
109
+ { label: 'Pages', items: pages(q) },
110
+ {
111
+ label: 'Invoices',
112
+ items: invoices.map((invoice) => ({
113
+ path: `/invoices/${invoice.id}`,
114
+ title: invoice.number || 'Draft invoice',
115
+ detail: [invoice.contactName, invoice.reference].filter(Boolean).join(' · '),
116
+ kind: 'invoices',
117
+ })),
118
+ },
119
+ { label: 'Jobs', items: matchingJobs(q) },
120
+ ]);
121
+ }
122
+
123
+ input.addEventListener('input', () => {
124
+ clearTimeout(timer);
125
+ timer = setTimeout(search, 150);
126
+ });
127
+ input.addEventListener('keydown', (event) => {
128
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
129
+ event.preventDefault();
130
+ if (!options.length) return;
131
+ const step = event.key === 'ArrowDown' ? 1 : -1;
132
+ select((active + step + options.length) % options.length);
133
+ } else if (event.key === 'Enter' && options[active]) {
134
+ event.preventDefault();
135
+ open(options[active].getAttribute('href'));
136
+ }
137
+ });
138
+ element.addEventListener('click', (event) => { if (event.target === element) element.close(); });
139
+ element.addEventListener('close', () => { clearTimeout(timer); element.remove(); });
140
+
141
+ document.body.append(element);
142
+ element.showModal();
143
+ input.focus();
144
+ search();
145
+ }