@arcforgelabs/flow 2026.10.39 → 2026.10.41
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/core/job-match.js +2 -0
- package/server/integrations/call-guard.js +57 -15
- package/server/integrations/xero/settings.js +2 -1
- package/server/ui/app.js +7 -3
- package/server/ui/flow.css +24 -38
- package/server/ui/invoicing.js +15 -53
- package/server/ui/jobs.js +15 -172
- package/server/ui/monitoring.js +13 -12
- package/server/ui/reconcile.js +279 -95
- package/server/ui/routes.js +1 -1
- package/server/ui/view-bar.js +236 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcforgelabs/flow",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.41",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@arcforgelabs/flow",
|
|
9
|
-
"version": "2026.10.
|
|
9
|
+
"version": "2026.10.41",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clerk/backend": "3.25.0",
|
package/package.json
CHANGED
package/server/core/job-match.js
CHANGED
|
@@ -97,6 +97,8 @@ export async function listReconcile(db, organisationId) {
|
|
|
97
97
|
practitioner: billingObligations.practitioner,
|
|
98
98
|
jobDate: billingObligations.jobDate,
|
|
99
99
|
totalCents: billingObligations.totalCents,
|
|
100
|
+
// An unclear accounting match: "Make invoice" settles it first.
|
|
101
|
+
unclear: sql`${billingObligations.accountingState} = 'ambiguous'`.mapWith(Boolean),
|
|
100
102
|
possible: sql`(select count(*) from invoices i
|
|
101
103
|
where i.organisation_id = ${billingObligations.organisationId} and i.contact_id = ${billingObligations.contactId}
|
|
102
104
|
and i.status <> 'void' and i.total_cents = ${billingObligations.totalCents}
|
|
@@ -9,7 +9,10 @@ import { sql } from 'drizzle-orm';
|
|
|
9
9
|
// - a refused credential halts every call until a person acts (saves the
|
|
10
10
|
// settings or connects again, which calls resume());
|
|
11
11
|
// - a 401 or 403 on one resource, when the adapter's auth failures stop a
|
|
12
|
-
// resource only, stops that resource
|
|
12
|
+
// resource only, stops that resource; after `resourceRetryMs` one probe
|
|
13
|
+
// call may go, and a refused probe stops it again for twice as long, to
|
|
14
|
+
// at most `maxResourceRetryMs`. A good call, or a person connecting
|
|
15
|
+
// again, starts it again;
|
|
13
16
|
// - `circuitFailures` failed calls inside `circuitWindowMs` open the
|
|
14
17
|
// circuit: no call for `circuitPauseMs`, then a probe; a failed probe opens
|
|
15
18
|
// it again for twice as long, to at most `maxCircuitPauseMs`, and a
|
|
@@ -34,6 +37,8 @@ export const CALL_GUARD_POLICY = Object.freeze({
|
|
|
34
37
|
// 'all': a 401 or 403 halts every call. 'resource': it stops only the
|
|
35
38
|
// resource it came from; halt() still stops everything.
|
|
36
39
|
authStops: 'all',
|
|
40
|
+
resourceRetryMs: 6 * 60 * 60_000,
|
|
41
|
+
maxResourceRetryMs: 24 * 60 * 60_000,
|
|
37
42
|
telemetryRetentionMs: 35 * 24 * 60 * 60_000,
|
|
38
43
|
});
|
|
39
44
|
|
|
@@ -68,12 +73,31 @@ export function circuitPauseMs(opens, policy = CALL_GUARD_POLICY) {
|
|
|
68
73
|
return Math.min(policy.maxCircuitPauseMs, policy.circuitPauseMs * 2 ** Math.min(Math.max(0, opens), 10));
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
// The wait before the next probe of a stopped resource, after `probes`
|
|
77
|
+
// refused probes.
|
|
78
|
+
export function resourceRetryMs(probes, policy = CALL_GUARD_POLICY) {
|
|
79
|
+
return Math.min(policy.maxResourceRetryMs, policy.resourceRetryMs * 2 ** Math.min(Math.max(0, probes), 10));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// When a stopped resource may be probed again (ms). An entry written before
|
|
83
|
+
// probes existed has no `retryAt`: it is due `resourceRetryMs` after `at`.
|
|
84
|
+
export function resourceRetryAt(entry, policy = CALL_GUARD_POLICY) {
|
|
85
|
+
const due = entry?.retryAt ? new Date(entry.retryAt).getTime() : NaN;
|
|
86
|
+
if (Number.isFinite(due)) return due;
|
|
87
|
+
const at = entry?.at ? new Date(entry.at).getTime() : NaN;
|
|
88
|
+
return Number.isFinite(at) ? at + resourceRetryMs(0, policy) : Infinity;
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
// Why no call may go now, or null. `state` is the guard row
|
|
72
92
|
// ({ haltedReason, circuitOpenUntil, stoppedResources }); `failedToday`
|
|
73
93
|
// counts failed calls since the start of the UTC day.
|
|
74
94
|
export function stoppedReason(state, { nowMs, failedToday, resource = null }, policy = CALL_GUARD_POLICY) {
|
|
75
95
|
if (state?.haltedReason) return { reason: 'halted', retryAfterMs: null };
|
|
76
|
-
|
|
96
|
+
const entry = resource ? state?.stoppedResources?.[resource] : null;
|
|
97
|
+
if (entry) {
|
|
98
|
+
const due = resourceRetryAt(entry, policy);
|
|
99
|
+
if (due > nowMs) return { reason: 'resource_stopped', retryAfterMs: due - nowMs, resource };
|
|
100
|
+
}
|
|
77
101
|
const openUntil = state?.circuitOpenUntil ? new Date(state.circuitOpenUntil).getTime() : 0;
|
|
78
102
|
if (openUntil > nowMs) return { reason: 'circuit_open', retryAfterMs: openUntil - nowMs };
|
|
79
103
|
const dayStart = startOfUtcDay(nowMs);
|
|
@@ -95,7 +119,7 @@ export function circuitAfterFailure(state, { nowMs, recentFailures }, policy = C
|
|
|
95
119
|
|
|
96
120
|
const STOP_TEXT = {
|
|
97
121
|
halted: (name) => `${name} calls are stopped until a person checks the connection (it was refused)`,
|
|
98
|
-
resource_stopped: (name, resource) => `${name} calls to ${resource} are stopped
|
|
122
|
+
resource_stopped: (name, resource) => `${name} calls to ${resource} are stopped (they were refused); Flow tries again later by itself`,
|
|
99
123
|
circuit_open: (name) => `${name} calls are paused after repeated failures`,
|
|
100
124
|
budget_spent: (name) => `${name} calls are stopped for today: the day's failed calls are spent`,
|
|
101
125
|
};
|
|
@@ -138,12 +162,14 @@ export function guardAlerts(name, status, { fix = 'Check the connection, then co
|
|
|
138
162
|
members: [`halted:${status.haltedAt ?? ''}`],
|
|
139
163
|
});
|
|
140
164
|
}
|
|
165
|
+
// One refused resource is a warning, not a stop: every other call still
|
|
166
|
+
// goes, and Flow probes the resource again by itself.
|
|
141
167
|
const resources = status?.stoppedResources ?? [];
|
|
142
168
|
if (resources.length) {
|
|
143
169
|
alerts.push({
|
|
144
170
|
id: 'resources-stopped',
|
|
145
|
-
tone: '
|
|
146
|
-
title: `${name} refused Flow for ${resources.map((r) => r.resource).join(', ')}. Flow
|
|
171
|
+
tone: 'warn',
|
|
172
|
+
title: `${name} refused Flow for ${resources.map((r) => r.resource).join(', ')}. Other ${name} calls still work. Flow tries again by itself. To fix it now, connect again.`,
|
|
147
173
|
members: resources.map((r) => `${r.resource}:${r.at ?? ''}`),
|
|
148
174
|
});
|
|
149
175
|
}
|
|
@@ -233,7 +259,8 @@ export function createCallGuard({
|
|
|
233
259
|
}
|
|
234
260
|
|
|
235
261
|
// Records how one call ended, and what that does to the guard. Answers
|
|
236
|
-
// what this call
|
|
262
|
+
// what this call changed ({ halted, resourceStopped, resourceStillStopped,
|
|
263
|
+
// resourceResumed, opened, closed, budgetSpent }),
|
|
237
264
|
// each of which was just reported once. `callId` names a call row the
|
|
238
265
|
// adapter wrote when it admitted the call: that row is updated, not added.
|
|
239
266
|
async function record(rawKey, { callId = null, resource = null, path = null, statusCode = null, failureKind = null, durationMs = null } = {}) {
|
|
@@ -256,22 +283,35 @@ export function createCallGuard({
|
|
|
256
283
|
}
|
|
257
284
|
const state = await readState(tx, key);
|
|
258
285
|
if (!countsAsFailure({ statusCode: status, failureKind })) {
|
|
286
|
+
const result = {};
|
|
287
|
+
// A good probe: the resource is allowed again.
|
|
288
|
+
if (where && state.stoppedResources[where] && status !== null && status < 400) {
|
|
289
|
+
await tx.execute(sql`update ${T.state} set stopped_resources = stopped_resources - ${where}, updated_at = ${nowAt} where ${C.key} = ${key}`);
|
|
290
|
+
result.resourceResumed = where;
|
|
291
|
+
}
|
|
259
292
|
if (state.circuitOpens > 0) {
|
|
260
293
|
await tx.execute(sql`update ${T.state} set circuit_opens = 0, updated_at = ${nowAt} where ${C.key} = ${key}`);
|
|
261
|
-
|
|
294
|
+
result.closed = true;
|
|
262
295
|
}
|
|
263
|
-
return
|
|
296
|
+
return result;
|
|
264
297
|
}
|
|
265
298
|
const result = {};
|
|
266
299
|
const dayStart = startOfUtcDay(nowMs);
|
|
267
300
|
if (await failuresSince(tx, key, dayStart) === policy.dailyFailureBudget) result.budgetSpent = { until: new Date(dayStart + DAY_MS).toISOString() };
|
|
268
301
|
if (isAuthRefusal(status)) {
|
|
269
302
|
if (policy.authStops === 'resource' && where) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
303
|
+
const before = state.stoppedResources[where];
|
|
304
|
+
const probes = before ? (Number(before.probes) || 0) + 1 : 0;
|
|
305
|
+
const entry = {
|
|
306
|
+
statusCode: status,
|
|
307
|
+
at: before?.at ?? nowAt.toISOString(),
|
|
308
|
+
probes,
|
|
309
|
+
retryAt: new Date(nowMs + resourceRetryMs(probes, policy)).toISOString(),
|
|
310
|
+
};
|
|
311
|
+
await tx.execute(sql`update ${T.state} set stopped_resources = stopped_resources || ${JSON.stringify({ [where]: entry })}::jsonb,
|
|
312
|
+
updated_at = ${nowAt} where ${C.key} = ${key}`);
|
|
313
|
+
if (before) result.resourceStillStopped = { resource: where, retryAt: entry.retryAt };
|
|
314
|
+
else result.resourceStopped = where;
|
|
275
315
|
return result;
|
|
276
316
|
}
|
|
277
317
|
if (!state.haltedReason) {
|
|
@@ -301,7 +341,9 @@ export function createCallGuard({
|
|
|
301
341
|
if (!reports) return;
|
|
302
342
|
const cause = status ? `HTTP ${status}` : failureKind;
|
|
303
343
|
if (outcome.halted) log.error?.(`${tag} ${name} refused the connection (${cause}). Calls are stopped until a person checks the ${name} settings or connects again.`);
|
|
304
|
-
if (outcome.resourceStopped) log.error?.(`${tag} ${name} refused calls to ${where} (${cause}). Calls to ${where} are stopped
|
|
344
|
+
if (outcome.resourceStopped) log.error?.(`${tag} ${name} refused calls to ${where} (${cause}). Calls to ${where} are stopped; Flow tries again by itself, or a person connects again.`);
|
|
345
|
+
if (outcome.resourceStillStopped) log.warn?.(`${tag} ${name} still refuses calls to ${where} (${cause}). Flow tries again at ${outcome.resourceStillStopped.retryAt}.`);
|
|
346
|
+
if (outcome.resourceResumed) log.info?.(`${tag} ${name} allows calls to ${outcome.resourceResumed} again.`);
|
|
305
347
|
if (outcome.opened) {
|
|
306
348
|
const line = `${tag} ${name} calls failed repeatedly (last: ${cause}${where ? ` on ${where}` : ''}). Calls pause until ${outcome.opened.until}.`;
|
|
307
349
|
if (outcome.opened.opens === 1) log.error?.(line);
|
|
@@ -352,7 +394,7 @@ export function createCallGuard({
|
|
|
352
394
|
circuitOpenUntil: openUntil > nowMs ? iso(openUntil) : null,
|
|
353
395
|
haltedReason: state.haltedReason,
|
|
354
396
|
haltedAt: iso(state.haltedAt),
|
|
355
|
-
stoppedResources: Object.entries(state.stoppedResources).map(([resource, entry]) => ({ resource, ...entry })),
|
|
397
|
+
stoppedResources: Object.entries(state.stoppedResources).map(([resource, entry]) => ({ resource, ...entry, retryAt: Number.isFinite(resourceRetryAt(entry, policy)) ? iso(resourceRetryAt(entry, policy)) : null })),
|
|
356
398
|
failedToday: await failuresSince(db, key, startOfUtcDay(nowMs)),
|
|
357
399
|
dailyFailureBudget: policy.dailyFailureBudget,
|
|
358
400
|
};
|
|
@@ -27,7 +27,8 @@ export const XERO_ENDPOINTS = Object.freeze({
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
export const XERO_CALLBACK_PATH = '/api/xero/oauth/callback';
|
|
30
|
-
|
|
30
|
+
// accounting.payments.read: Flow reads payments back (ledger.js, mirror.js).
|
|
31
|
+
export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.payments.read accounting.settings.read';
|
|
31
32
|
export const DEFAULT_XERO_DAY_LIMIT = 1000;
|
|
32
33
|
export const DEFAULT_DUE_DAYS = 30;
|
|
33
34
|
export const DEFAULT_MIRROR_PAGE_SIZE = 200;
|
package/server/ui/app.js
CHANGED
|
@@ -1001,16 +1001,20 @@ function xeroConnection(organisations, canChange) {
|
|
|
1001
1001
|
let hint;
|
|
1002
1002
|
let control = null;
|
|
1003
1003
|
let stopped = false;
|
|
1004
|
+
let refused = false;
|
|
1004
1005
|
try {
|
|
1005
1006
|
const status = await flow(`/api/xero/status?organisationId=${organisation.id}`);
|
|
1006
1007
|
if (status.connected && status.calls?.stopped) {
|
|
1007
1008
|
// A refused connection stopped calls (the call guard): say so, and
|
|
1008
1009
|
// make Connect again the main button, which starts them again.
|
|
1009
1010
|
const at = status.calls.since ? ` at ${new Date(status.calls.since).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })}` : '';
|
|
1011
|
+
// One refused resource stops only that resource, and Flow tries it
|
|
1012
|
+
// again by itself: a calm note, not a stop.
|
|
1010
1013
|
hint = status.calls.all
|
|
1011
1014
|
? `Xero refused Flow${at}. Flow stopped all calls.`
|
|
1012
|
-
: `Xero refused Flow for ${status.calls.resources.join(', ')}${at}. Flow
|
|
1013
|
-
stopped =
|
|
1015
|
+
: `Connected. Xero refused Flow for ${status.calls.resources.join(', ')}${at}. Other calls still work. Flow tries again by itself.`;
|
|
1016
|
+
stopped = status.calls.all;
|
|
1017
|
+
refused = !status.calls.all;
|
|
1014
1018
|
} else if (status.connected) {
|
|
1015
1019
|
const mirror = await flow(`/api/xero/mirror?organisationId=${organisation.id}`).catch(() => null);
|
|
1016
1020
|
hint = `Connected to ${status.connection?.tenantName ?? 'Xero'}.${mirror ? ` ${mirror.counts.invoices} invoices copied.` : ''}`;
|
|
@@ -1018,7 +1022,7 @@ function xeroConnection(organisations, canChange) {
|
|
|
1018
1022
|
hint = 'Not connected.';
|
|
1019
1023
|
}
|
|
1020
1024
|
if (canChange) {
|
|
1021
|
-
control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
1025
|
+
control = h('button', { class: `fx-btn fx-btn--sm${status.connected && !stopped && !refused ? '' : ' fx-btn--primary'}`, type: 'button', onclick: async () => {
|
|
1022
1026
|
try {
|
|
1023
1027
|
const { authorizeUrl } = await flow('/api/xero/connect', { method: 'POST', body: JSON.stringify({ organisationId: organisation.id }) });
|
|
1024
1028
|
location.assign(authorizeUrl);
|
package/server/ui/flow.css
CHANGED
|
@@ -387,9 +387,6 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
387
387
|
.fx-tabs button[aria-selected="true"] { color: var(--t-strong); border-bottom-color: var(--t-brand); }
|
|
388
388
|
.fx-tabs__count { padding: 0 7px; border-radius: 999px; background: var(--fx-hover); color: var(--t-dim); font-size: 12px; line-height: 20px; }
|
|
389
389
|
.fx-tabs button[data-tone="danger"] .fx-tabs__count { background: var(--t-danger-bg); color: var(--t-danger); }
|
|
390
|
-
/* Invoices: the Send all ready bar above the Ready list. */
|
|
391
|
-
.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; }
|
|
392
|
-
.fx-sendbar[hidden] { display: none; }
|
|
393
390
|
|
|
394
391
|
/* Quiet alerts (server/ui/alerts.js): one slim line under the page header,
|
|
395
392
|
only while something on the page needs this person. */
|
|
@@ -409,9 +406,7 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
409
406
|
.fx-alerts__text { flex-basis: calc(100% - 52px); }
|
|
410
407
|
.fx-alerts__action, .fx-alerts__more { order: 2; }
|
|
411
408
|
}
|
|
412
|
-
.fx-sendbar p { margin: 0; }
|
|
413
409
|
.fx-problem { color: var(--t-danger); }
|
|
414
|
-
.fx-sendbar__actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
415
410
|
.fx-tick-cell { width: 40px; }
|
|
416
411
|
.fx-tick { width: 16px; height: 16px; accent-color: var(--t-brand); cursor: pointer; }
|
|
417
412
|
.fx-search { width: min(320px, 100%); margin-bottom: 8px; }
|
|
@@ -705,26 +700,15 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
705
700
|
.fx-task__none { display: inline-flex; align-items: center; gap: 6px; color: var(--t-warn); }
|
|
706
701
|
.fx-task__none svg { width: 16px; height: 16px; }
|
|
707
702
|
|
|
708
|
-
/* Reconcile: the
|
|
709
|
-
|
|
710
|
-
.fx-rec
|
|
711
|
-
.fx-
|
|
712
|
-
.fx-
|
|
713
|
-
.fx-
|
|
714
|
-
.fx-
|
|
715
|
-
.fx-rec__search { width: 100%; min-width: 0; }
|
|
703
|
+
/* Reconcile (reconcile.js): the list, as Jobs and Invoices; one gap, as an
|
|
704
|
+
invoice opened from Invoices (the pager on top). */
|
|
705
|
+
.fx-rec { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
|
706
|
+
.fx-rec .fx-lucide { width: 16px; height: 16px; }
|
|
707
|
+
.fx-rec__gap { max-width: 1180px; }
|
|
708
|
+
.fx-rec__gap .fx-toolbar .fx-rows__title { margin: 0; }
|
|
709
|
+
.fx-rec__gap .fx-ph__actions .fx-btn, .fx-rec__acts .fx-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
|
716
710
|
.fx-rec__results { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
|
717
|
-
.fx-
|
|
718
|
-
.fx-rec__item:hover { background: var(--fx-hover); }
|
|
719
|
-
.fx-rec__item[aria-current="true"] { border-left-color: var(--t-accent); background: var(--t-s1); }
|
|
720
|
-
.fx-rec__item-top { display: flex; justify-content: space-between; gap: 8px; }
|
|
721
|
-
.fx-rec__item .fx-num { font-family: var(--t-data); font-variant-numeric: tabular-nums; }
|
|
722
|
-
.fx-rec__detail { display: flex; flex-direction: column; gap: 16px; min-width: 0; padding: 20px 24px; }
|
|
723
|
-
.fx-rec__head { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px; flex-wrap: wrap; }
|
|
724
|
-
.fx-rec__head > div { display: grid; gap: 2px; }
|
|
725
|
-
.fx-rec__back { display: none; color: var(--t-accent); text-decoration: none; font-size: 13px; }
|
|
726
|
-
.fx-rec__title { margin: 0; font-family: var(--t-display); font-weight: var(--t-display-weight); color: var(--t-strong); font-size: 24px; }
|
|
727
|
-
.fx-rec__table { display: grid; grid-template-columns: 110px minmax(150px, 1fr) repeat(var(--cols), minmax(170px, 1fr)); border: 1px solid var(--fx-line); border-radius: var(--t-radius); overflow-x: auto; }
|
|
711
|
+
.fx-rec__table { display: grid; grid-template-columns: 110px minmax(150px, 1fr) repeat(var(--cols), minmax(170px, 1fr)); border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); overflow-x: auto; }
|
|
728
712
|
.fx-rec__row { display: contents; }
|
|
729
713
|
.fx-rec__row > span { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; border-top: 1px solid var(--fx-line-soft); font-size: 13.5px; color: var(--t-strong); }
|
|
730
714
|
.fx-rec__row:first-child > span { border-top: 0; background: var(--t-s1); }
|
|
@@ -738,8 +722,6 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
738
722
|
[data-mark="same"] > .fx-rec__mark { color: var(--t-success); }
|
|
739
723
|
[data-mark="differs"], [data-mark="differs"] > .fx-rec__mark { color: var(--t-warn); }
|
|
740
724
|
.fx-rec__best { color: var(--t-success); font-size: 12px; font-weight: 700; }
|
|
741
|
-
.fx-rec__foot { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
742
|
-
.fx-rec__foot-acts { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
743
725
|
.fx-rec__cards { display: none; }
|
|
744
726
|
.fx-rec__card { display: flex; flex-direction: column; gap: 6px; padding: 12px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); font-size: 13.5px; color: var(--t-strong); }
|
|
745
727
|
.fx-rec__card.fx-rec__job { border-color: transparent; }
|
|
@@ -747,20 +729,17 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
747
729
|
.fx-rec__card-top { display: flex; justify-content: space-between; gap: 8px; }
|
|
748
730
|
.fx-rec__label { color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
749
731
|
.fx-rec__checks { display: flex; flex-wrap: wrap; gap: 4px 12px; }
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
.fx-rec__back { display: inline; }
|
|
756
|
-
}
|
|
732
|
+
.fx-rec-list tbody tr { cursor: pointer; }
|
|
733
|
+
.fx-rec-list__kind { width: 36px; color: var(--t-dim); }
|
|
734
|
+
.fx-rec-list__mid { width: 72px; text-align: center; white-space: nowrap; }
|
|
735
|
+
.fx-rec__possible { display: inline-flex; align-items: center; gap: 4px; color: var(--t-dim); font-variant-numeric: tabular-nums; }
|
|
736
|
+
.fx-rec__possible[data-state="some"] { color: var(--t-warn); }
|
|
757
737
|
@media (max-width: 820px) {
|
|
758
|
-
.fx-rec__detail { padding: 16px; }
|
|
759
738
|
.fx-rec__table { display: none; }
|
|
760
739
|
.fx-rec__cards { display: flex; flex-direction: column; gap: 12px; }
|
|
761
|
-
.fx-rec__cards .fx-btn, .fx-
|
|
762
|
-
.fx-
|
|
763
|
-
.fx-
|
|
740
|
+
.fx-rec__cards .fx-btn, .fx-rec__gap .fx-ph__actions .fx-btn { min-height: 44px; }
|
|
741
|
+
.fx-rec__gap .fx-ph__actions { width: 100%; }
|
|
742
|
+
.fx-rec__gap .fx-ph__actions .fx-btn { flex: 1 1 auto; }
|
|
764
743
|
}
|
|
765
744
|
.fx-task__custom { margin-top: 24px; }
|
|
766
745
|
.fx-task__custom h3 { margin: 0 0 4px; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
@@ -1293,7 +1272,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1293
1272
|
Narrow, the app bar (60px) sits above it. */
|
|
1294
1273
|
.fx-listhead { position: sticky; top: 0; z-index: 5; margin: -12px calc(-1 * clamp(16px, 4vw, 48px)) 0; padding: 12px clamp(16px, 4vw, 48px) 0; background: var(--t-bg); transition: box-shadow .15s; }
|
|
1295
1274
|
.fx-listhead[data-stuck] { box-shadow: 0 10px 12px -12px rgba(0, 0, 0, .45); }
|
|
1296
|
-
.fx-listhead .fx-sendbar { margin-bottom: 12px; }
|
|
1297
1275
|
.fx-listhead__edge { height: 0; margin-bottom: -20px; }
|
|
1298
1276
|
@media (max-width: 820px) { .fx-listhead { top: 60px; } }
|
|
1299
1277
|
/* On a phone the head would hide too many rows: it scrolls away. */
|
|
@@ -1389,3 +1367,11 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1389
1367
|
}
|
|
1390
1368
|
.fx-xi-count { font-size: 18px; }
|
|
1391
1369
|
.fx-xi-list td.fx-num:not(.fx-strong) { text-align: left; }
|
|
1370
|
+
|
|
1371
|
+
/* A list's own search (view-bar.js findBox): an icon that opens into a box. */
|
|
1372
|
+
.fx-find { display: inline-flex; align-items: center; }
|
|
1373
|
+
.fx-find__button { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; }
|
|
1374
|
+
.fx-find__button .fx-lucide { width: 15px; height: 15px; }
|
|
1375
|
+
.fx-find__input { display: none; width: min(240px, 60vw); height: 32px; padding: 0 10px; }
|
|
1376
|
+
.fx-find--open .fx-find__input { display: block; }
|
|
1377
|
+
.fx-find--open .fx-find__button { display: none; }
|
package/server/ui/invoicing.js
CHANGED
|
@@ -10,6 +10,7 @@ import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
|
10
10
|
import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
|
|
11
11
|
import { caseNumber } from '/assets/ui/job-board.js';
|
|
12
12
|
import { jobChangeBanner } from '/assets/ui/job-change.js';
|
|
13
|
+
import { findBox } from '/assets/ui/view-bar.js';
|
|
13
14
|
|
|
14
15
|
// The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
|
|
15
16
|
// person must look), Ready (checks pass), then the accounting system's
|
|
@@ -118,17 +119,24 @@ export async function renderInvoiceList(main, ui) {
|
|
|
118
119
|
sessionStorage.removeItem(NAV_BACK);
|
|
119
120
|
const state = { status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set() };
|
|
120
121
|
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
|
|
121
|
-
|
|
122
|
-
const
|
|
122
|
+
let timer;
|
|
123
|
+
const find = findBox(h, {
|
|
124
|
+
label: 'Find a number, client or reference', value: state.q,
|
|
125
|
+
oninput: (words) => {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
timer = setTimeout(() => { state.q = words.trim(); load(); }, 200);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
123
130
|
const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
124
131
|
const bulk = bulkBar(ui);
|
|
125
132
|
const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
|
|
126
133
|
let body = null;
|
|
127
134
|
const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
|
|
128
135
|
const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
|
|
129
|
-
// The tabs
|
|
130
|
-
//
|
|
131
|
-
|
|
136
|
+
// The tabs and search stay at the top while the rows scroll up under them;
|
|
137
|
+
// the bulk bar floats at the foot (ClickUp's layout). Sending is a bulk
|
|
138
|
+
// action: tick rows (the head tick takes every row shown), then Send.
|
|
139
|
+
const head = h('div', { class: 'fx-listhead' }, h('div', { class: 'fx-toolbar' }, tabs, find.element));
|
|
132
140
|
const edge = h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' });
|
|
133
141
|
main.append(
|
|
134
142
|
pageHeader('Invoices', {
|
|
@@ -144,56 +152,11 @@ export async function renderInvoiceList(main, ui) {
|
|
|
144
152
|
new IntersectionObserver(([entry]) => head.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${head.offsetTop > 0 && window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1}px 0px 0px 0px` }).observe(edge);
|
|
145
153
|
}
|
|
146
154
|
|
|
147
|
-
let timer;
|
|
148
|
-
search.addEventListener('input', () => {
|
|
149
|
-
clearTimeout(timer);
|
|
150
|
-
timer = setTimeout(() => { state.q = search.value.trim(); load(); }, 200);
|
|
151
|
-
});
|
|
152
155
|
|
|
153
156
|
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
154
|
-
|
|
155
|
-
const el = h('button', {
|
|
156
|
-
class: `fx-btn fx-btn--${tone}`, type: 'button',
|
|
157
|
-
onclick: async () => {
|
|
158
|
-
el.disabled = true;
|
|
159
|
-
try {
|
|
160
|
-
const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload()) });
|
|
161
|
-
toast(...done(res));
|
|
162
|
-
state.ticked.clear();
|
|
163
|
-
seen.clear();
|
|
164
|
-
await load();
|
|
165
|
-
alerts.refresh();
|
|
166
|
-
} catch (error) {
|
|
167
|
-
toast(error.message, 'danger');
|
|
168
|
-
el.disabled = false;
|
|
169
|
-
}
|
|
170
|
-
},
|
|
171
|
-
}, label);
|
|
172
|
-
return el;
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
// The bar above the list: what a person can do with this view.
|
|
157
|
+
// The bulk bar for the ticked rows: what a person can do with this view.
|
|
176
158
|
function drawBar() {
|
|
177
|
-
const to = body.sendsTo;
|
|
178
159
|
const ticked = [...state.ticked];
|
|
179
|
-
const parts = [];
|
|
180
|
-
if (state.status === 'ready') {
|
|
181
|
-
parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
|
|
182
|
-
const buttons = [];
|
|
183
|
-
if (context.canIssue && body.counts.ready > 0) {
|
|
184
|
-
buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Approve all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
|
|
185
|
-
(res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
|
|
186
|
-
}
|
|
187
|
-
if (buttons.length) parts.push(h('div', { class: 'fx-sendbar__actions' }, buttons));
|
|
188
|
-
} else if (state.status === 'pending') {
|
|
189
|
-
const failed = body.invoices.filter((i) => i.status === 'issued' && i.problems.some((p) => p.code === 'send_failed' || p.code?.startsWith('xero_'))).length;
|
|
190
|
-
if (context.canIssue && to && failed > 0) {
|
|
191
|
-
parts.push(h('p', { class: 'fx-dim' }, `${plural(failed, 'invoice', 'invoices')} did not reach ${to}. Fix the cause, then send again.`));
|
|
192
|
-
parts.push(action('Send again', 'send-again', () => ({}), (res) => [`${res.queued} on the way to ${to} again.`, 'success']));
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
bar.replaceChildren(...parts);
|
|
196
|
-
bar.hidden = !parts.length;
|
|
197
160
|
bulk.draw({ count: ticked.length, onClear: () => setTicks([]), ...bulkActions(ticked) });
|
|
198
161
|
}
|
|
199
162
|
|
|
@@ -228,7 +191,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
228
191
|
const apply = [];
|
|
229
192
|
if (state.status === 'ready' && context.canIssue) {
|
|
230
193
|
status.push({
|
|
231
|
-
icon: to ? 'send' : 'circle-check-big', label: to ?
|
|
194
|
+
icon: to ? 'send' : 'circle-check-big', label: to ? `Send to ${to}` : 'Approve', primary: true,
|
|
232
195
|
title: to ? `Send to ${to}` : 'Approve',
|
|
233
196
|
run: async () => {
|
|
234
197
|
const ok = await ui.dialog({
|
|
@@ -352,7 +315,6 @@ export async function renderInvoiceList(main, ui) {
|
|
|
352
315
|
draw(seen.get(key));
|
|
353
316
|
} else if (body) {
|
|
354
317
|
drawTabs(body.counts);
|
|
355
|
-
bar.hidden = true;
|
|
356
318
|
host.dataset.stale = '';
|
|
357
319
|
}
|
|
358
320
|
host.setAttribute('aria-busy', 'true');
|