@arcforgelabs/flow 2026.10.53 → 2026.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/SECURITY.md +7 -4
- package/npm-shrinkwrap.json +442 -4
- package/package.json +8 -3
- package/server/agents/auth-worker.js +7 -0
- package/server/agents/auth.js +50 -0
- package/server/agents/gate.js +138 -0
- package/server/agents/grants.js +51 -0
- package/server/agents/reads.js +47 -0
- package/server/agents/server.js +74 -0
- package/server/agents/settings.js +24 -0
- package/server/app.js +9 -0
- package/server/core/ledger.js +90 -16
- package/server/core/line-mapping.js +2 -2
- package/server/core/read-before-act.js +2 -2
- package/server/core/reconcile.js +15 -1
- package/server/core/sending.js +20 -2
- package/server/db/migrations/0025_powerful_leopardon.sql +1 -0
- package/server/db/migrations/0026_bitter_sersi.sql +38 -0
- package/server/db/migrations/meta/0025_snapshot.json +4709 -0
- package/server/db/migrations/meta/0026_snapshot.json +4926 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +38 -1
- package/server/index.js +9 -1
- package/server/integrations/port.js +1 -0
- package/server/integrations/seazona/history/backfill.js +3 -1
- package/server/integrations/seazona/history/index.js +4 -7
- package/server/integrations/seazona/index.js +5 -1
- package/server/integrations/seazona/lanes.js +44 -27
- package/server/integrations/seazona/ledger.js +17 -6
- package/server/integrations/seazona/reconcile.js +12 -0
- package/server/rate-limit.js +2 -1
- package/server/ui/app.js +74 -62
- package/server/ui/flow.css +36 -5
- package/server/ui/invoicing.js +34 -7
- package/server/ui/mobile.js +17 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/sync-status.js +67 -0
package/server/ui/invoicing.js
CHANGED
|
@@ -1154,20 +1154,31 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
1154
1154
|
const sendButton = context.canIssue && to && ready ? h('button', {
|
|
1155
1155
|
class: 'fx-btn fx-btn--primary', type: 'button',
|
|
1156
1156
|
onclick: async () => {
|
|
1157
|
-
if (
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
confirm: emails ? 'Send and email' : 'Send',
|
|
1162
|
-
});
|
|
1163
|
-
if (!ok) return;
|
|
1157
|
+
if (sendButton.disabled) return;
|
|
1158
|
+
sendButton.disabled = true;
|
|
1159
|
+
const label = sendButton.textContent;
|
|
1160
|
+
let cancelled = false;
|
|
1164
1161
|
try {
|
|
1162
|
+
if (changed() && !(await save())) return;
|
|
1163
|
+
const ok = await ui.dialog({
|
|
1164
|
+
title: `Send this invoice to ${to}?`,
|
|
1165
|
+
body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
|
|
1166
|
+
confirm: emails ? 'Send and email' : 'Send',
|
|
1167
|
+
});
|
|
1168
|
+
if (!ok) { cancelled = true; return; }
|
|
1169
|
+
sendButton.setAttribute('aria-busy', 'true');
|
|
1170
|
+
sendButton.replaceChildren(h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }), 'Sending…');
|
|
1165
1171
|
await flow('/api/invoicing/invoices/send-ready', { method: 'POST', body: JSON.stringify({ issueDate: localDate(), invoiceIds: [invoice.id] }) });
|
|
1166
1172
|
const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
|
|
1167
1173
|
toast(`${fresh.number} is on the way to ${to}.`, 'success');
|
|
1168
1174
|
openNext(ui, invoice.id);
|
|
1169
1175
|
} catch (error) {
|
|
1170
1176
|
toast(error.message, 'danger', { stay: true });
|
|
1177
|
+
} finally {
|
|
1178
|
+
sendButton.disabled = false;
|
|
1179
|
+
sendButton.removeAttribute('aria-busy');
|
|
1180
|
+
sendButton.textContent = label;
|
|
1181
|
+
if (cancelled && sendButton.isConnected && document.activeElement === document.body) sendButton.focus();
|
|
1171
1182
|
}
|
|
1172
1183
|
},
|
|
1173
1184
|
}, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
|
|
@@ -1423,6 +1434,21 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1423
1434
|
? h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise') : null;
|
|
1424
1435
|
const preview = previewButton(ui, invoice.id);
|
|
1425
1436
|
actionBar = h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean));
|
|
1437
|
+
// The same problem notice as a draft. A matched Xero invoice gets the
|
|
1438
|
+
// same Open in Xero link as the Xero card (go.xero.com, reconcile.js).
|
|
1439
|
+
const found = invoice.problems ?? [];
|
|
1440
|
+
const shorts = found.length && found.every((p) => p.short) ? [...new Set(found.map((p) => p.short))].join(', ') : null;
|
|
1441
|
+
const problemNotice = found.length
|
|
1442
|
+
? ui.notice([...new Set(found.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.', { short: shorts })
|
|
1443
|
+
: null;
|
|
1444
|
+
const matched = found.find((p) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(p.xero?.invoiceId ?? ''));
|
|
1445
|
+
const openXero = matched ? h('a', {
|
|
1446
|
+
class: 'fx-btn fx-btn--sm fx-btn--ghost',
|
|
1447
|
+
href: `https://go.xero.com/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(matched.xero.invoiceId)}`,
|
|
1448
|
+
target: '_blank',
|
|
1449
|
+
rel: 'noopener',
|
|
1450
|
+
}, 'Open in Xero') : null;
|
|
1451
|
+
const problemRow = problemNotice ? (openXero ? h('div', { class: 'fx-offer' }, problemNotice, openXero) : problemNotice) : null;
|
|
1426
1452
|
const header = h('header', { class: 'fx-ph' },
|
|
1427
1453
|
h('div', { class: 'fx-ph__text' },
|
|
1428
1454
|
h('h1', { class: 'fx-title' }, invoice.number),
|
|
@@ -1449,6 +1475,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1449
1475
|
chrome.record,
|
|
1450
1476
|
chrome.pager,
|
|
1451
1477
|
header,
|
|
1478
|
+
problemRow,
|
|
1452
1479
|
h('div', { class: 'fx-invoice__grid' },
|
|
1453
1480
|
h('div', { class: 'fx-invoice__main' },
|
|
1454
1481
|
h('section', { class: 'fx-card fx-doc' },
|
package/server/ui/mobile.js
CHANGED
|
@@ -618,6 +618,23 @@ export const RULES = {
|
|
|
618
618
|
],
|
|
619
619
|
"card": "Sidecar"
|
|
620
620
|
},
|
|
621
|
+
"syncStatus": {
|
|
622
|
+
"component": "syncStatus",
|
|
623
|
+
"direction": "Layers",
|
|
624
|
+
"phone": {
|
|
625
|
+
"layout": "wrap"
|
|
626
|
+
},
|
|
627
|
+
"tablet": {
|
|
628
|
+
"layout": "row"
|
|
629
|
+
},
|
|
630
|
+
"desk": {
|
|
631
|
+
"layout": "row"
|
|
632
|
+
},
|
|
633
|
+
"touch": 44,
|
|
634
|
+
"pageOverflow": "none",
|
|
635
|
+
"screens": [],
|
|
636
|
+
"card": "SyncStatus"
|
|
637
|
+
},
|
|
621
638
|
"fx-tabs": {
|
|
622
639
|
"component": "fx-tabs",
|
|
623
640
|
"direction": "scrolling tabs",
|
package/server/ui/routes.js
CHANGED
|
@@ -22,7 +22,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
22
22
|
const logoDir = join(here, 'logos');
|
|
23
23
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
24
24
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
25
|
-
export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
25
|
+
export const UI_FILES = new Set(['sync-status.js', 'flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'captures.js', 'capture-fields.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
26
26
|
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
27
27
|
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
28
28
|
const MAX_AGE = '1h';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { h } from './kit.js';
|
|
2
|
+
import { applyPhoneRule } from './mobile.js';
|
|
3
|
+
|
|
4
|
+
const DAY = 86_400_000;
|
|
5
|
+
const words = ['Polishing', 'Glazing', 'Milling', 'Sintering'];
|
|
6
|
+
const date = value => value ? new Date(`${value}T00:00:00Z`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', timeZone: 'UTC' }) : '';
|
|
7
|
+
const duration = ms => { const seconds = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; };
|
|
8
|
+
|
|
9
|
+
// Neutral, read-only view of server state. No inferred event history. The caller
|
|
10
|
+
// owns actions and polling; dispose stops the display clock when unmounted.
|
|
11
|
+
export function syncStatus() {
|
|
12
|
+
const word = h('span', { class: 'fx-sync__word' });
|
|
13
|
+
const dots = h('span', { class: 'fx-sync__dots', 'aria-hidden': 'true' }, ...[0, 1, 2].map(() => h('i')));
|
|
14
|
+
const result = h('span');
|
|
15
|
+
const status = h('span', { class: 'fx-sync__status', role: 'status', 'aria-live': 'polite' }, dots, word, result);
|
|
16
|
+
const clock = h('span', { class: 'fx-sync__clock', 'aria-label': 'Elapsed', 'aria-live': 'off' });
|
|
17
|
+
const action = h('span', { class: 'fx-sync__action' });
|
|
18
|
+
const rail = h('progress', { class: 'fx-sync__rail', max: 1, value: 0, 'aria-label': 'Days read' });
|
|
19
|
+
const range = h('span');
|
|
20
|
+
const eta = h('span');
|
|
21
|
+
const track = h('div', { class: 'fx-sync__track' }, rail, h('span', { class: 'fx-sync__range' }, range, eta));
|
|
22
|
+
const element = applyPhoneRule(h('div', { class: 'fx-sync' }, h('div', { class: 'fx-sync__line' }, status, clock, action), track), 'syncStatus');
|
|
23
|
+
let current = {};
|
|
24
|
+
let timer;
|
|
25
|
+
const reduced = matchMedia('(prefers-reduced-motion: reduce)');
|
|
26
|
+
const updateClock = () => {
|
|
27
|
+
const { phase, startedAt, endedAt, nextRunAt, count, from, through, at } = current;
|
|
28
|
+
const active = ['running', 'waiting'].includes(phase);
|
|
29
|
+
const elapsed = (active ? Date.now() : Date.parse(endedAt)) - Date.parse(startedAt);
|
|
30
|
+
clock.textContent = Number.isFinite(elapsed) ? duration(elapsed) : '';
|
|
31
|
+
// Rotating words and ticking clocks are decorative, not live announcements.
|
|
32
|
+
if (phase === 'running' && !count) word.textContent = reduced.matches ? 'Reading' : words[Math.floor(Math.max(0, elapsed) / 2600) % words.length] ?? 'Reading';
|
|
33
|
+
eta.textContent = '';
|
|
34
|
+
if (phase === 'waiting' && nextRunAt) {
|
|
35
|
+
const seconds = Math.max(0, Math.ceil((Date.parse(nextRunAt) - Date.now()) / 1000));
|
|
36
|
+
clock.textContent = `${clock.textContent}${clock.textContent ? ' · ' : ''}${seconds}s until retry`;
|
|
37
|
+
}
|
|
38
|
+
const completed = Date.parse(at) - Date.parse(from);
|
|
39
|
+
const total = Date.parse(through) - Date.parse(from) + DAY;
|
|
40
|
+
if (phase === 'running' && count > 0 && completed > 0 && total > completed && elapsed > 0) {
|
|
41
|
+
eta.textContent = `about ${Math.max(1, Math.ceil(elapsed * (total - completed) / completed / 60000))} min left`;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const draw = (state, button = null) => {
|
|
45
|
+
current = state;
|
|
46
|
+
const { phase = 'not-started', count, unit = 'jobs', from, through, at, cause } = state;
|
|
47
|
+
element.dataset.phase = phase;
|
|
48
|
+
const running = phase === 'running';
|
|
49
|
+
dots.hidden = !running || count > 0;
|
|
50
|
+
word.setAttribute('aria-hidden', String(running && !count));
|
|
51
|
+
status.setAttribute('aria-label', running && !count ? 'Reading' : '');
|
|
52
|
+
word.textContent = running ? (count > 0 ? `Reading${at ? ` ${date(at)}` : ''}` : 'Reading') : ({ waiting: 'Waiting', stopped: 'Stopped', failed: 'Failed', done: 'Done', loading: 'Checking…' }[phase] ?? 'Not started');
|
|
53
|
+
result.textContent = `${count != null && (count > 0 || !running) ? ` · ${count.toLocaleString('en-AU')} ${unit}` : ''}${cause ? ` · ${cause}` : ''}${phase === 'stopped' && at ? ` · ${date(at)}` : ''}`;
|
|
54
|
+
if (action.firstChild !== button) action.replaceChildren(...(button ? [button] : []));
|
|
55
|
+
const total = Date.parse(through) - Date.parse(from) + DAY;
|
|
56
|
+
track.hidden = !(count > 0 && total > 0);
|
|
57
|
+
rail.value = phase === 'done' ? 1 : Math.min(1, Math.max(0, (Date.parse(at) - Date.parse(from)) / total || 0));
|
|
58
|
+
range.textContent = track.hidden ? '' : `${date(from)} – ${date(through)}`;
|
|
59
|
+
clearInterval(timer);
|
|
60
|
+
updateClock();
|
|
61
|
+
if (['running', 'waiting'].includes(phase)) timer = setInterval(() => {
|
|
62
|
+
if (!element.isConnected) { clearInterval(timer); return; }
|
|
63
|
+
if (!document.hidden) updateClock();
|
|
64
|
+
}, 1000);
|
|
65
|
+
};
|
|
66
|
+
return { element, draw, dispose: () => clearInterval(timer) };
|
|
67
|
+
}
|