@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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.39",
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.39",
9
+ "version": "2026.10.41",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.39",
3
+ "version": "2026.10.41",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 until a person acts;
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
- if (resource && state?.stoppedResources?.[resource]) return { reason: 'resource_stopped', retryAfterMs: null, resource };
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 until a person checks the connection (they were refused)`,
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: 'danger',
146
- title: `${name} refused Flow for ${resources.map((r) => r.resource).join(', ')}. Flow stopped those calls. ${fix}`,
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 stopped ({ halted, resourceStopped, opened, budgetSpent }),
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
- return { closed: true };
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
- if (!state.stoppedResources[where]) {
271
- await tx.execute(sql`update ${T.state} set stopped_resources = stopped_resources || ${JSON.stringify({ [where]: { statusCode: status, at: nowAt.toISOString() } })}::jsonb,
272
- updated_at = ${nowAt} where ${C.key} = ${key}`);
273
- result.resourceStopped = where;
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 until a person checks the connection.`);
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
- export const DEFAULT_XERO_SCOPE = 'offline_access accounting.contacts accounting.invoices accounting.settings.read';
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 stopped those calls.`;
1013
- stopped = true;
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);
@@ -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 queue left, one job and its possible invoices right. */
709
- .fx-rec { display: grid; grid-template-columns: 280px minmax(0, 1fr); border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); background: var(--t-paper); min-height: 420px; }
710
- .fx-rec > .fx-empty, .fx-rec > .fx-dim { grid-column: 1 / -1; }
711
- .fx-rec__queue { display: flex; flex-direction: column; border-right: 1px solid var(--fx-line); max-height: 75vh; overflow-y: auto; }
712
- .fx-rec__tabs { display: flex; flex-wrap: wrap; gap: 6px; padding: 12px 16px; border-bottom: 1px solid var(--fx-line); }
713
- .fx-rec__tab { display: inline-flex; align-items: center; min-height: 32px; padding: 0 10px; border: 1px solid var(--fx-line); border-radius: 999px; color: var(--t-text); font-size: 12.5px; font-weight: 600; text-decoration: none; }
714
- .fx-rec__tab[aria-selected="true"] { border-color: var(--t-primary); background: var(--t-primary); color: var(--t-on-primary); }
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-rec__item { display: grid; gap: 3px; min-height: 44px; padding: 12px 16px; border-bottom: 1px solid var(--fx-line-soft); border-left: 3px solid transparent; color: var(--t-text); text-decoration: none; }
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
- @media (max-width: 1100px) {
751
- .fx-rec { grid-template-columns: minmax(0, 1fr); min-height: 0; }
752
- .fx-rec__queue { border-right: 0; max-height: none; }
753
- .fx-rec--open .fx-rec__queue { display: none; }
754
- .fx-rec:not(.fx-rec--open) .fx-rec__detail { display: none; }
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-rec__foot .fx-btn { min-height: 44px; }
762
- .fx-rec__foot-acts { width: 100%; }
763
- .fx-rec__foot-acts .fx-btn { flex: 1 1 auto; }
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; }
@@ -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
- const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice', value: state.q });
122
- const bar = h('div', { class: 'fx-sendbar', hidden: true });
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, search and send bar stay at the top while the rows scroll up
130
- // under them; the bulk bar floats at the foot (ClickUp's layout).
131
- const head = h('div', { class: 'fx-listhead' }, h('div', { class: 'fx-toolbar' }, tabs, search), bar);
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
- const action = (label, path, payload, done, tone = 'primary') => {
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 ? 'Send' : 'Approve', primary: true,
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');