@arcforgelabs/flow 2026.10.53 → 2026.11.2

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 (45) hide show
  1. package/README.md +1 -0
  2. package/SECURITY.md +7 -4
  3. package/npm-shrinkwrap.json +442 -4
  4. package/package.json +8 -3
  5. package/server/agents/auth-worker.js +7 -0
  6. package/server/agents/auth.js +50 -0
  7. package/server/agents/gate.js +138 -0
  8. package/server/agents/grants.js +51 -0
  9. package/server/agents/reads.js +47 -0
  10. package/server/agents/server.js +74 -0
  11. package/server/agents/settings.js +24 -0
  12. package/server/api/invoicing.js +27 -10
  13. package/server/app.js +9 -0
  14. package/server/core/invoice-rules.js +25 -10
  15. package/server/core/job-board.js +12 -4
  16. package/server/core/ledger.js +110 -22
  17. package/server/core/line-mapping.js +9 -5
  18. package/server/core/read-before-act.js +2 -2
  19. package/server/core/reconcile.js +15 -1
  20. package/server/core/sending.js +33 -9
  21. package/server/db/migrations/0025_powerful_leopardon.sql +1 -0
  22. package/server/db/migrations/0026_bitter_sersi.sql +38 -0
  23. package/server/db/migrations/meta/0025_snapshot.json +4709 -0
  24. package/server/db/migrations/meta/0026_snapshot.json +4926 -0
  25. package/server/db/migrations/meta/_journal.json +14 -0
  26. package/server/db/schema.js +38 -1
  27. package/server/index.js +9 -1
  28. package/server/integrations/port.js +1 -0
  29. package/server/integrations/seazona/history/backfill.js +3 -1
  30. package/server/integrations/seazona/history/index.js +4 -7
  31. package/server/integrations/seazona/index.js +5 -1
  32. package/server/integrations/seazona/lanes.js +44 -27
  33. package/server/integrations/seazona/ledger.js +17 -6
  34. package/server/integrations/seazona/reconcile.js +12 -0
  35. package/server/rate-limit.js +2 -1
  36. package/server/ui/app.js +76 -64
  37. package/server/ui/flow.css +43 -8
  38. package/server/ui/invoice-rules.js +24 -3
  39. package/server/ui/invoicing.js +76 -13
  40. package/server/ui/job-board.js +12 -7
  41. package/server/ui/job-change.js +63 -15
  42. package/server/ui/jobs.js +4 -4
  43. package/server/ui/mobile.js +17 -0
  44. package/server/ui/routes.js +1 -1
  45. package/server/ui/sync-status.js +67 -0
@@ -230,6 +230,9 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
230
230
  background: var(--t-danger-bg); color: var(--t-text); font-size: 14px;
231
231
  }
232
232
  .fx-change p { margin: 0; }
233
+ .fx-change__steps { display: grid; gap: 10px; min-width: 0; overflow-wrap: anywhere; }
234
+ .fx-change__steps ol { display: grid; gap: 8px; margin: 0; padding-left: 20px; }
235
+ .fx-change__steps summary { cursor: pointer; }
233
236
  .fx-change strong { color: var(--t-danger); }
234
237
  .fx-change__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; }
235
238
  .fx-change__acts { display: flex; flex-wrap: wrap; gap: 8px; }
@@ -639,9 +642,13 @@ a.fx-strong:hover { text-decoration: underline; }
639
642
  .fx-pop__tick { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; color: var(--t-accent); }
640
643
  .fx-pop__sep { height: 1px; margin: 6px 4px; background: var(--fx-line-soft); }
641
644
  /* A row with a trailing control (the sidebar pin). The control does not
642
- close the menu. An unpinned pin shows when the pointer is on the row. */
643
- .fx-pop__row { display: flex; align-items: center; gap: 2px; }
645
+ close the menu. An unpinned pin shows when the pointer is on the row.
646
+ The whole row lights on hover; the pin under the pointer goes back to the
647
+ menu's own colour, so both read. */
648
+ .fx-pop__row { display: flex; align-items: center; gap: 2px; padding-right: 4px; border-radius: var(--t-radius); }
649
+ .fx-pop__row:hover, .fx-pop__row:has(:focus-visible) { background: var(--fx-hover); }
644
650
  .fx-pop__row > .fx-pop__item { flex: 1; min-width: 0; }
651
+ .fx-pop__row > .fx-pop__item:is(:hover, :focus-visible) { background: none; }
645
652
  .fx-pop__row > .fx-pop__item > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
646
653
  .fx-pop__pin, .fx-pop__pin--fixed {
647
654
  display: inline-grid; place-items: center; flex: none; width: 28px; height: 28px;
@@ -654,13 +661,14 @@ a.fx-strong:hover { text-decoration: underline; }
654
661
  .fx-pop__row:focus-within .fx-pop__pin[aria-pressed="false"],
655
662
  .fx-pop__pin:focus-visible { opacity: 1; }
656
663
  .fx-pop__pin[aria-pressed="true"], .fx-pop__pin--fixed { color: var(--t-strong); opacity: 1; }
657
- .fx-pop__pin:hover, .fx-pop__pin:focus-visible { background: var(--fx-hover); color: var(--t-strong); outline: none; }
664
+ .fx-pop__pin:hover, .fx-pop__pin:focus-visible { background: var(--t-paper); color: var(--t-strong); outline: none; }
658
665
  .fx-pop.fx-pop--side { min-width: 240px; }
659
666
  .fx-pop--side .fx-pop__tick { color: inherit; }
660
667
  @media (hover: none) { .fx-pop__pin[aria-pressed="false"] { opacity: 1; } }
661
668
  @media (max-width: 820px) {
662
669
  .fx-pop--side .fx-pop__item { min-height: 44px; }
663
670
  .fx-pop--side .fx-pop__pin, .fx-pop--side .fx-pop__pin--fixed { width: 44px; height: 44px; }
671
+ .fx-pop--side .fx-pop__row { padding-right: 0; }
664
672
  }
665
673
  .fx-pop__foot { display: flex; justify-content: flex-end; padding: 6px 4px 2px; border-top: 1px solid var(--fx-line-soft); margin-top: 6px; }
666
674
  /* A chip with an × that clears it (as ClickUp's "Clear all filters"). */
@@ -833,7 +841,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
833
841
  each lane scrolls inside itself (jobs.js sets fx-main--board while the
834
842
  board shows). */
835
843
  .fx-board { --fx-lane: min(85vw, 272px); display: grid; grid-auto-flow: column; grid-auto-columns: var(--fx-lane); justify-content: start; gap: 12px; align-items: start; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 4px; }
836
- .fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
844
+ .fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background: transparent; }
837
845
  .fx-board__head { display: flex; flex: none; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 4px 2px; }
838
846
  .fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
839
847
  .fx-board__cards { display: grid; gap: 8px; align-content: start; min-height: 0; max-height: 70vh; padding-right: 2px; overflow-y: auto; scrollbar-width: thin; }
@@ -855,7 +863,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
855
863
  .fx-fade[data-edge="right"] { inset: 0 0 0 auto; width: 24px; background: linear-gradient(to left, var(--fx-fade), transparent); }
856
864
  .fx-fades[data-fade~="top"] > .fx-fade[data-edge="top"], .fx-fades[data-fade~="bottom"] > .fx-fade[data-edge="bottom"],
857
865
  .fx-fades[data-fade~="left"] > .fx-fade[data-edge="left"], .fx-fades[data-fade~="right"] > .fx-fade[data-edge="right"] { opacity: 1; }
858
- .fx-main--board .fx-frame__view { --fx-fade: var(--t-bg); }
866
+ .fx-main--board .fx-frame__view { --fx-fade: var(--t-s1); }
859
867
  .fx-board__col.fx-fades { --fx-fade: var(--t-s1); }
860
868
  .fx-board__col > .fx-fade[data-edge="bottom"] { border-radius: 0 0 var(--t-radius-lg) var(--t-radius-lg); }
861
869
  .fx-frame__body .fx-panel { border: 0; border-radius: 0; box-shadow: none; overflow: visible; }
@@ -864,7 +872,8 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
864
872
  .fx-frame thead th { position: sticky; top: 0; z-index: 2; }
865
873
  .fx-frame__foot { display: flex; flex: none; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; padding: 6px 12px; border-top: 1px solid var(--fx-line); color: var(--t-dim); font-size: 13px; font-variant-numeric: tabular-nums; }
866
874
  .fx-frame__tools { display: flex; align-items: center; gap: 8px; }
867
- .fx-main--board .fx-frame__body { padding: 12px; background: var(--t-bg); }
875
+ /* One work surface runs through the lanes, gaps and unused board space. */
876
+ .fx-main--board .fx-frame__body { padding: 12px; background: var(--t-s1); }
868
877
  @media (min-width: 821px) and (min-height: 560px) {
869
878
  .fx-main--frame { height: 100vh; height: 100dvh; padding-bottom: 16px; display: flex; flex-direction: column; }
870
879
  .fx-main--frame > * { flex: none; }
@@ -1287,9 +1296,32 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1287
1296
  .fx-setting__head { display: flex; align-items: center; gap: 2px; min-width: 0; }
1288
1297
  .fx-setting__head .fx-help { margin: -4px 0; border: 0; background: none; cursor: pointer; }
1289
1298
  .fx-setting__hint { margin: 0; color: var(--t-dim); font-size: 13px; line-height: 1.45; }
1290
- .fx-history__status { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
1291
1299
  .fx-history__day { width: 160px; flex: none; }
1292
- .fx-history__bar { width: 100%; height: 8px; accent-color: var(--t-brand); }
1300
+ /* SyncStatus: Layers, shared by integration history readers. */
1301
+ .fx-history { padding: 8px 20px 16px; }
1302
+ .fx-sync { min-width: 0; padding-block: 8px; }
1303
+ .fx-sync__line { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
1304
+ .fx-sync__status { min-width: 0; overflow-wrap: anywhere; color: var(--t-text); font-size: 13px; }
1305
+ .fx-sync__word { position: relative; display: inline-block; overflow: hidden; vertical-align: bottom; }
1306
+ .fx-sync[data-phase="running"] .fx-sync__word::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, var(--t-paper), transparent); opacity: .5; transform: translateX(-100%); animation: fx-sync-sweep 2.6s linear infinite; }
1307
+ .fx-sync[data-phase="failed"] .fx-sync__word { color: var(--t-danger); }
1308
+ .fx-sync[data-phase="done"] .fx-sync__word { color: var(--t-success); }
1309
+ .fx-sync__clock { font: 12px var(--t-data); color: var(--t-dim); }
1310
+ .fx-sync__action { margin-left: auto; }
1311
+ .fx-sync__action .fx-btn { min-width: 44px; min-height: 44px; }
1312
+ .fx-sync__dots { display: inline-flex; gap: 3px; margin-right: 8px; vertical-align: middle; }
1313
+ .fx-sync__dots[hidden], .fx-sync__track[hidden] { display: none; }
1314
+ .fx-sync__dots i { width: 4px; height: 4px; border-radius: var(--t-radius); background: var(--t-accent); animation: fx-sync-breathe 1.8s ease-in-out infinite; }
1315
+ .fx-sync__dots i:nth-child(2) { animation-delay: .2s; }
1316
+ .fx-sync__dots i:nth-child(3) { animation-delay: .4s; }
1317
+ .fx-sync__track { display: grid; gap: 4px; margin-top: 8px; }
1318
+ .fx-sync__rail { width: 100%; height: 4px; accent-color: var(--t-brand); }
1319
+ .fx-sync__range { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; color: var(--t-dim); font-size: 12px; }
1320
+ @media (max-width: 639px) {
1321
+ .fx-sync__line { gap: 4px 8px; }
1322
+ .fx-sync__status { flex: 1 1 60%; }
1323
+ .fx-sync__clock { order: 1; flex-basis: 100%; }
1324
+ }
1293
1325
  .fx-setting[data-tone="danger"] .fx-setting__hint { color: var(--t-danger); }
1294
1326
  .fx-setting__control { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
1295
1327
  .fx-setting__control > .fx-input, .fx-setting__control > .fx-select, .fx-setting__stack { width: 320px; max-width: 100%; }
@@ -1539,9 +1571,12 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1539
1571
  }
1540
1572
 
1541
1573
  @keyframes fx-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
1574
+ @keyframes fx-sync-breathe { 50% { opacity: .35; transform: scale(.75); } }
1575
+ @keyframes fx-sync-sweep { to { transform: translateX(100%); } }
1542
1576
  @keyframes fx-spin { to { transform: rotate(360deg); } }
1543
1577
  @keyframes fx-shimmer { to { background-position: -200% 0; } }
1544
1578
  @media (prefers-reduced-motion: reduce) {
1579
+ .fx-sync__word::after { display: none; }
1545
1580
  *, *::before, *::after { animation: none !important; transition: none !important; }
1546
1581
  }
1547
1582
 
@@ -47,6 +47,9 @@ export function ruleSummary(rule, meta) {
47
47
  export function rulesSection(ui, title = 'Rules') {
48
48
  const { h, flow, toast, notice, dialog, me, settingRow, iconPicker } = ui;
49
49
  const disabled = !['owner', 'admin'].includes(me?.person.role);
50
+ // A Pending invoice opens the exact rule responsible, using this same
51
+ // conditions/editor UI. Read-only users see the rule, never write controls.
52
+ const targetRule = new URLSearchParams(location.search).get('rule');
50
53
  const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
51
54
  let meta;
52
55
  let rules = [];
@@ -177,13 +180,25 @@ export function rulesSection(ui, title = 'Rules') {
177
180
  if (editors.length === 1) return toast('A rule needs at least one condition.', 'danger');
178
181
  editors = editors.filter((e) => e.element !== element);
179
182
  element.remove();
183
+ validateAction();
180
184
  return null;
181
185
  });
182
186
  editors.push(editor);
183
187
  list.append(editor.element);
188
+ validateAction();
184
189
  return null;
185
190
  };
191
+ // Draft-only fields cannot hold work before a draft exists. Native form
192
+ // validation keeps this editor and its choices open instead of a refused PUT.
193
+ const validateAction = () => {
194
+ const field = editors.map((e) => meta.fields.find((f) => f.key === e.read().field)).find((f) => f?.draftOnly);
195
+ action.setCustomValidity(action.value !== 'pending' && field
196
+ ? `${field.label} checks require Pending, with a note. Choose Pending or remove that condition.` : '');
197
+ };
198
+ list.addEventListener('change', validateAction);
199
+ action.addEventListener('change', validateAction);
186
200
  for (const c of draft?.conditions ?? [null]) addCondition(c);
201
+ validateAction();
187
202
  const more = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Add condition');
188
203
  more.addEventListener('click', () => addCondition());
189
204
 
@@ -242,16 +257,22 @@ export function rulesSection(ui, title = 'Rules') {
242
257
  open.addEventListener('click', () => edit(rule));
243
258
  controls.unshift(open);
244
259
  }
245
- return row(rule.name, ruleSummary(rule, meta), controls);
260
+ const element = row(rule.name, ruleSummary(rule, meta), controls);
261
+ element.setAttribute('role', 'region');
262
+ element.setAttribute('aria-label', `Rule ${rule.name}`);
263
+ return element;
246
264
  };
247
265
 
248
266
  function draw() {
249
- const add = disabled ? null : h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Add rule');
267
+ const shown = targetRule ? rules.filter((rule) => rule.id === targetRule) : rules;
268
+ const add = disabled || targetRule ? null : h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Add rule');
250
269
  add?.addEventListener('click', () => edit(null));
251
270
  section.replaceChildren(
252
271
  h('h2', { class: 'fx-set__title' }, title),
253
272
  h('p', { class: 'fx-set__hint', style: { margin: '0 0 4px' } }, 'When a rule matches, a ready job waits, is never drafted, or its draft is Pending with the rule\'s note.'),
254
- ...(rules.length ? rules.map(rowOf) : [h('p', { class: 'fx-dim' }, 'No rules. Every ready job is drafted.')]),
273
+ ...(shown.length ? shown.map(rowOf) : [targetRule
274
+ ? notice('This rule is no longer available. Reopen the invoice to check its remaining problems.', 'danger')
275
+ : h('p', { class: 'fx-dim' }, 'No rules. Every ready job is drafted.')]),
255
276
  h('div', { class: 'fx-rule__footer' }, add,
256
277
  h('a', { class: 'fx-dim', href: DOCS, target: '_blank', rel: 'noopener' }, 'How rules work')));
257
278
  }
@@ -8,7 +8,7 @@
8
8
  import { alertStrip } from '/assets/ui/alerts.js';
9
9
  import { bulkBar } from '/assets/ui/bulk-bar.js';
10
10
  import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
11
- import { caseNumber } from '/assets/ui/job-board.js';
11
+ import { caseNumber, fixes } from '/assets/ui/job-board.js';
12
12
  import { jobChangeReview } from '/assets/ui/job-change.js';
13
13
  import { cardRows, rowMenu } from '/assets/ui/kit.js';
14
14
  import { isPhone, WIDTHS } from '/assets/ui/mobile.js';
@@ -981,6 +981,20 @@ function draftView(invoice, ui, context, redraw) {
981
981
  // item. A check on something this page cannot change (the job's
982
982
  // practitioner) marks nothing; the banner says it.
983
983
  const problemList = invoice?.problems ?? [];
984
+ // Dispatch intents to the real editor or shared job comparison, not a
985
+ // parallel warning-code list. Missing data/unknown intents stay blocked.
986
+ // Adapter warnings retain their existing owner-specific actions.
987
+ const hasRuleReview = (p) => p.resolution?.kind === 'rule_review' && Boolean(p.rule);
988
+ const hasJobHold = (p) => p.resolution?.kind === 'job_hold' && Boolean(invoice?.job?.id)
989
+ && typeof p.resolution.reason === 'string' && Boolean(p.resolution.reason.trim());
990
+ const hasJobReview = (p) => p.resolution?.kind === 'job_review' && invoice?.jobChange
991
+ && Boolean(p.sourceBilling) === Boolean(invoice.jobChange.sourceReview);
992
+ // The source check runs first. Its Recheck reveals the ordinary job
993
+ // comparison once the source problem clears; that view is not missing.
994
+ const deferredJobReview = (p) => p.resolution?.kind === 'job_review'
995
+ && !p.sourceBilling && Boolean(invoice?.jobChange?.sourceReview);
996
+ const unavailable = problemList.filter((p) => p.resolution
997
+ && p.resolution.kind !== 'invoice_edit' && !hasJobReview(p) && !deferredJobReview(p) && !hasRuleReview(p) && !hasJobHold(p));
984
998
  const failed = new Set(problemList.flatMap((p) => p.fields ?? []));
985
999
  const badLines = new Set(problemList.filter((p) => p.fields?.includes('line_item')).map((p) => p.line));
986
1000
  const boxes = { reference };
@@ -1146,7 +1160,7 @@ function draftView(invoice, ui, context, redraw) {
1146
1160
  // it and queues it, the same as Send all ready. With none, Flow issues it
1147
1161
  // and keeps it. A Pending draft has no send: fix its problem first.
1148
1162
  const to = invoice?.sendsTo ?? context.sendsTo ?? null;
1149
- const ready = invoice?.view === 'ready';
1163
+ const ready = invoice?.view === 'ready' && !problemList.length;
1150
1164
  // A lab's rule keeps a non-chargeable draft in Flow: nothing sends or approves it.
1151
1165
  const nonChargeable = invoice?.view === 'non_chargeable';
1152
1166
  const nextNumber = `${context.organisations[0]?.invoicePrefix ?? ''}${String(context.organisations[0]?.nextInvoiceNumber ?? '').padStart(4, '0')}`;
@@ -1154,20 +1168,31 @@ function draftView(invoice, ui, context, redraw) {
1154
1168
  const sendButton = context.canIssue && to && ready ? h('button', {
1155
1169
  class: 'fx-btn fx-btn--primary', type: 'button',
1156
1170
  onclick: async () => {
1157
- if (changed() && !(await save())) return;
1158
- const ok = await ui.dialog({
1159
- title: `Send this invoice to ${to}?`,
1160
- body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
1161
- confirm: emails ? 'Send and email' : 'Send',
1162
- });
1163
- if (!ok) return;
1171
+ if (sendButton.disabled) return;
1172
+ sendButton.disabled = true;
1173
+ const label = sendButton.textContent;
1174
+ let cancelled = false;
1164
1175
  try {
1176
+ if (changed() && !(await save())) return;
1177
+ const ok = await ui.dialog({
1178
+ title: `Send this invoice to ${to}?`,
1179
+ body: h('p', {}, `It gets the next number (${nextNumber}). ${sendModeNote(to, invoice.sendMode, 'it')}`),
1180
+ confirm: emails ? 'Send and email' : 'Send',
1181
+ });
1182
+ if (!ok) { cancelled = true; return; }
1183
+ sendButton.setAttribute('aria-busy', 'true');
1184
+ sendButton.replaceChildren(h('span', { class: 'fx-spinner fx-spinner--sm', 'aria-hidden': 'true' }), 'Sending…');
1165
1185
  await flow('/api/invoicing/invoices/send-ready', { method: 'POST', body: JSON.stringify({ issueDate: localDate(), invoiceIds: [invoice.id] }) });
1166
1186
  const { invoice: fresh } = await flow(`/api/invoicing/invoices/${invoice.id}`);
1167
1187
  toast(`${fresh.number} is on the way to ${to}.`, 'success');
1168
1188
  openNext(ui, invoice.id);
1169
1189
  } catch (error) {
1170
1190
  toast(error.message, 'danger', { stay: true });
1191
+ } finally {
1192
+ sendButton.disabled = false;
1193
+ sendButton.removeAttribute('aria-busy');
1194
+ sendButton.textContent = label;
1195
+ if (cancelled && sendButton.isConnected && document.activeElement === document.body) sendButton.focus();
1171
1196
  }
1172
1197
  },
1173
1198
  }, emails ? `Send to ${to} and email` : `Send to ${to}`) : null;
@@ -1199,8 +1224,8 @@ function draftView(invoice, ui, context, redraw) {
1199
1224
  const syncIssue = () => {
1200
1225
  if (!issueButton) return;
1201
1226
  const missing = [contact ? null : 'a client', dueDate.value ? null : 'a due date'].filter(Boolean);
1202
- issueButton.disabled = missing.length > 0 || Boolean(invoice?.jobChange);
1203
- issueButton.title = invoice?.jobChange ? 'Fix the problem first.' : missing.length ? `Choose ${missing.join(' and ')} first.` : 'Gives it the next number and locks it. Nothing is sent.';
1227
+ issueButton.disabled = missing.length > 0 || Boolean(invoice?.jobChange) || problemList.length > 0;
1228
+ issueButton.title = invoice?.jobChange || problemList.length ? 'Fix the problem first.' : missing.length ? `Choose ${missing.join(' and ')} first.` : 'Gives it the next number and locks it. Nothing is sent.';
1204
1229
  };
1205
1230
  onContact = syncIssue;
1206
1231
  clientInput.addEventListener('input', syncIssue);
@@ -1225,7 +1250,7 @@ function draftView(invoice, ui, context, redraw) {
1225
1250
  const jobChange = invoice?.jobChange ?? null;
1226
1251
  // The job changed after the invoice was made: its own banner shows what
1227
1252
  // changed. Other problems stay one line.
1228
- const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
1253
+ const otherProblems = problemList.filter((p) => !hasJobReview(p));
1229
1254
  const offer = otherProblems.find((p) => ['xero_contact_missing', 'xero_contact_ambiguous'].includes(p.code) && (p.offerLink || p.offerCreate));
1230
1255
  // A phone shows each problem's short name ("Client not in Xero") on one line.
1231
1256
  const shorts = otherProblems.every((p) => p.short) ? [...new Set(otherProblems.map((p) => p.short))].join(', ') : null;
@@ -1240,10 +1265,32 @@ function draftView(invoice, ui, context, redraw) {
1240
1265
  done: async () => redraw((await flow(`/api/invoicing/invoices/${invoice.id}`)).invoice),
1241
1266
  })
1242
1267
  : [];
1268
+ // Every matching rule links to its own conditions and existing controls,
1269
+ // including custom rules. The target enforces read-only roles itself.
1270
+ const ruleActions = [...new Map(problemList.filter(hasRuleReview).map((p) => [p.rule, p])).values()]
1271
+ .map((p) => h('a', { class: 'fx-btn fx-btn--sm',
1272
+ href: `/invoicing-settings/invoices?rule=${encodeURIComponent(p.rule)}`,
1273
+ onclick: (event) => go(event),
1274
+ }, `Review ${p.short ?? 'rule'}`));
1275
+ // Non-change holds use the Jobs workflow's existing instructions, not a
1276
+ // comparison that does not exist. Reading them never releases the hold.
1277
+ const holdSteps = fixes({ view: 'review_required', holdState: 'review',
1278
+ reasons: [...new Set(problemList.filter(hasJobHold).map((p) => p.resolution.reason))] });
1279
+ const holdReview = holdSteps.length ? h('section', { 'aria-label': 'Job hold review' },
1280
+ ...holdSteps.map((step) => h('p', {}, step.todo)),
1281
+ invoice.job.link?.url ? h('a', { class: 'fx-btn fx-btn--sm', href: invoice.job.link.url, target: '_blank', rel: 'noopener' },
1282
+ `Open in ${invoice.job.systemName ?? 'work system'}`)
1283
+ : h('p', {}, `The source link is unavailable. Check job ${invoice.job.sourceJobRef ?? ''} in the work system, or ask an administrator to restore its link.`),
1284
+ h('p', {}, deleteButton
1285
+ ? 'If this unissued draft is no longer needed, use Delete. It remains Pending while the job is in review.'
1286
+ : 'Ask an owner or administrator to review this draft. It remains Pending while the job is in review.')) : null;
1243
1287
  const problems = [
1288
+ holdReview,
1244
1289
  // Accepting changes the draft for the business: owners and admins only.
1245
1290
  jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
1246
- problemNotice ? (clientActions.length ? h('div', { class: 'fx-offer' }, problemNotice, ...clientActions) : problemNotice) : null,
1291
+ problemList.some(deferredJobReview) ? notice('The job also changed after this invoice was made. Resolve the source amount problem first. Select Recheck, then review the remaining job changes.', 'danger') : null,
1292
+ unavailable.length ? notice('The resolution view is unavailable. Ask an administrator to restore it, then reopen this invoice. It remains Pending.', 'danger', 'Cannot resolve.') : null,
1293
+ problemNotice ? (clientActions.length || ruleActions.length ? h('div', { class: 'fx-offer' }, problemNotice, ...clientActions, ...ruleActions) : problemNotice) : null,
1247
1294
  ];
1248
1295
  // A red field names the banner as its reason (for a screen reader).
1249
1296
  const banner = problemNotice;
@@ -1423,6 +1470,21 @@ function issuedView(invoice, ui, context, redraw) {
1423
1470
  ? h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise') : null;
1424
1471
  const preview = previewButton(ui, invoice.id);
1425
1472
  actionBar = h('div', { class: 'fx-ph__actions' }, [voidButton, reviseButton, preview, send].filter(Boolean));
1473
+ // The same problem notice as a draft. A matched Xero invoice gets the
1474
+ // same Open in Xero link as the Xero card (go.xero.com, reconcile.js).
1475
+ const found = invoice.problems ?? [];
1476
+ const shorts = found.length && found.every((p) => p.short) ? [...new Set(found.map((p) => p.short))].join(', ') : null;
1477
+ const problemNotice = found.length
1478
+ ? ui.notice([...new Set(found.map((p) => brief(p.message)))].join(' '), 'danger', 'Problem.', { short: shorts })
1479
+ : null;
1480
+ 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 ?? ''));
1481
+ const openXero = matched ? h('a', {
1482
+ class: 'fx-btn fx-btn--sm fx-btn--ghost',
1483
+ href: `https://go.xero.com/AccountsReceivable/View.aspx?InvoiceID=${encodeURIComponent(matched.xero.invoiceId)}`,
1484
+ target: '_blank',
1485
+ rel: 'noopener',
1486
+ }, 'Open in Xero') : null;
1487
+ const problemRow = problemNotice ? (openXero ? h('div', { class: 'fx-offer' }, problemNotice, openXero) : problemNotice) : null;
1426
1488
  const header = h('header', { class: 'fx-ph' },
1427
1489
  h('div', { class: 'fx-ph__text' },
1428
1490
  h('h1', { class: 'fx-title' }, invoice.number),
@@ -1449,6 +1511,7 @@ function issuedView(invoice, ui, context, redraw) {
1449
1511
  chrome.record,
1450
1512
  chrome.pager,
1451
1513
  header,
1514
+ problemRow,
1452
1515
  h('div', { class: 'fx-invoice__grid' },
1453
1516
  h('div', { class: 'fx-invoice__main' },
1454
1517
  h('section', { class: 'fx-card fx-doc' },
@@ -126,7 +126,16 @@ function reasonFix(reason) {
126
126
  // system's) as a job fix. Flow's own invoice problems keep their words. A
127
127
  // problem an accounting system found stays on the invoice page; the job only
128
128
  // says that its invoice needs a fix.
129
+ function ruleFix(problem) {
130
+ const supported = problem.resolution?.kind === 'rule_review' && Boolean(problem.rule);
131
+ return { label: problem.short ?? 'Stopped by a rule', text: sentence(problem.message),
132
+ todo: supported ? 'Review this rule and the job facts, then reopen the job to check again.' : 'The rule resolution is unavailable. Reopen this job after an administrator restores it.',
133
+ go: supported ? 'rule' : null, rule: problem.rule ?? null,
134
+ };
135
+ }
136
+
129
137
  function draftFix(problem) {
138
+ if (problem.resolution?.kind === 'rule_review') return ruleFix(problem);
130
139
  const code = problem.code ?? '';
131
140
  const fix = (label, todo) => ({ label, text: sentence(problem.message ?? label), todo, go: 'invoice' });
132
141
  if (code === 'no_lines') return fix('No lines', 'Open the invoice and add the lines.');
@@ -141,7 +150,7 @@ function draftFix(problem) {
141
150
  // Where a fix's link goes: Flow's invoice, the job in the work system, a
142
151
  // Flow settings page, `review`: the job's Review sidecar, or `choose`: Make
143
152
  // invoice and Link invoice buttons (jobs.js draws them).
144
- export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'review', 'choose'];
153
+ export const GO = ['invoice', 'work', 'price-list', 'rule', 'integrations', 'review', 'choose'];
145
154
 
146
155
  // What a person must fix on a job, in order: [{ label, text, todo, go }];
147
156
  // `todo` is null when the buttons are the instruction.
@@ -152,11 +161,7 @@ export function fixes(o) {
152
161
  if (invoiceStage(o) !== 'problem') return [];
153
162
  const found = [];
154
163
  if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
155
- if (o.view === 'ready') {
156
- found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
157
- label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Rules.', go: 'invoicing-settings',
158
- })));
159
- }
164
+ found.push(...(o.waitingRules ?? []).map(ruleFix));
160
165
  if (o.invoiceStatus === 'void') {
161
166
  found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
162
167
  }
@@ -164,7 +169,7 @@ export function fixes(o) {
164
169
  found.push(...(o.invoiceProblems ?? []).filter((p) => !(held && p.code === 'job_review')).map((p) => draftFix(p)));
165
170
  const seen = new Set();
166
171
  return found.filter((f) => {
167
- const key = `${f.label}\u0000${f.text}`;
172
+ const key = `${f.rule ?? ''}\u0000${f.label}\u0000${f.text}`;
168
173
  if (seen.has(key)) return false;
169
174
  seen.add(key);
170
175
  return true;
@@ -16,6 +16,16 @@ const lineFull = (line, money) => `${lineText(line)} · ${line.quantity} × ${li
16
16
  // [{ field, was, now }] in plain words. `money` formats cents.
17
17
  export function changeRows(change, money) {
18
18
  const rows = [];
19
+ if (change.sourceReview) {
20
+ const { sourceTotals, reconstructed } = change.sourceReview;
21
+ return [['Subtotal', 'subtotalCents'], ['GST', 'taxCents'], ['Total', 'totalCents']].map(([field, key]) => {
22
+ const source = sourceTotals?.[key];
23
+ const flow = reconstructed[key];
24
+ const delta = source == null || flow == null ? null : flow - source;
25
+ return { field, was: source == null ? 'Not supplied' : money(source), now: flow == null ? 'Unknown' : money(flow),
26
+ difference: delta == null ? 'Unknown' : delta === 0 ? 'Same' : `${delta > 0 ? '+' : '−'}${money(Math.abs(delta))}`, num: true };
27
+ });
28
+ }
19
29
  if (change.contact) rows.push({ field: 'Client', was: change.contact.was ?? '—', now: change.contact.now ?? '—' });
20
30
  for (const line of change.lines ?? []) {
21
31
  const at = `Line ${line.position}`;
@@ -36,6 +46,7 @@ export function changeRows(change, money) {
36
46
  // The sentence after "Problem." One fact per sentence.
37
47
  export function changeSummary(change, rowCount) {
38
48
  const system = change.systemName ?? 'the work system';
49
+ if (change.sourceReview) return 'Check the source invoice amounts.';
39
50
  const count = `${rowCount} ${rowCount === 1 ? 'change' : 'changes'}.`;
40
51
  if (change.canAdopt) return `Changed in ${system}. ${count}`;
41
52
  if (change.sent) return `Changed in ${system} after sending. ${count}`;
@@ -48,44 +59,73 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
48
59
  const { h } = ui;
49
60
  const system = change.systemName ?? 'the work system';
50
61
  const rows = changeRows(change, money);
51
- const order = change.sourceInvoiceRef ? `Order #${change.sourceInvoiceRef}` : null;
62
+ const source = change.sourceReview;
63
+ const order = change.sourceInvoiceRef ? `${source ? 'Invoice' : 'Order'} #${change.sourceInvoiceRef}` : null;
52
64
  const link = change.link
53
65
  ? h('a', {
54
66
  class: 'fx-btn fx-btn--sm', href: change.link.url, target: '_blank', rel: 'noopener noreferrer',
55
67
  title: change.link.exact ? `Opens this job in ${system}.` : `Opens ${system}. Find ${order ?? 'the job'} there.`,
56
- }, `Open in ${system} ↗`)
68
+ }, source ? (change.link.exact ? `Open job in ${system} ↗` : `Open ${system} ↗`) : `Open in ${system} ↗`)
57
69
  : null;
58
70
  const accept = change.canAccept && onAccept
59
71
  ? h('button', { class: 'fx-btn fx-btn--sm fx-btn--primary', type: 'button', 'data-action': 'accept', title: `This invoice takes the Now in ${system} column.`, onclick: onAccept }, 'Accept change')
60
72
  : null;
61
- const recheck = onRecheck
73
+ const recheck = onRecheck && change.canRecheck !== false
62
74
  ? h('button', {
63
- class: 'fx-btn fx-btn--sm fx-btn--icon fx-change__recheck', type: 'button', title: `Check ${system} again`, 'aria-label': `Check ${system} again`,
75
+ class: `fx-btn fx-btn--sm${source ? '' : ' fx-btn--icon fx-change__recheck'}`, type: 'button', title: `Read this job from ${system} again. Nothing is sent.`, 'aria-label': source ? 'Recheck' : `Check ${system} again`,
64
76
  onclick: async (event) => {
65
77
  const button = event.currentTarget;
66
78
  button.disabled = true;
67
79
  button.classList.add('is-busy');
68
- try { await onRecheck(); } finally { button.disabled = false; button.classList.remove('is-busy'); }
80
+ button.setAttribute('aria-busy', 'true');
81
+ try { await onRecheck(); } finally { button.disabled = false; button.classList.remove('is-busy'); button.removeAttribute('aria-busy'); }
69
82
  },
70
- }, h('span', { class: 'fx-lucide', style: '--icon: url(/assets/icons/refresh-cw.svg)', 'aria-hidden': 'true' }))
83
+ }, source ? 'Recheck' : h('span', { class: 'fx-lucide', style: '--icon: url(/assets/icons/refresh-cw.svg)', 'aria-hidden': 'true' }))
71
84
  : null;
72
85
  const body = h('tbody', {});
86
+ const cell = (area, label, value, num) => h('td', {
87
+ class: num ? 'fx-num' : '', ...(source ? { 'data-area': area, 'data-label': label } : {}),
88
+ }, value);
73
89
  const draw = (all) => body.replaceChildren(...(all ? rows : rows.slice(0, FIRST_ROWS)).map((row) => h('tr', {},
74
- h('th', { scope: 'row' }, row.field),
75
- h('td', { class: `fx-change__was${row.num ? ' fx-num' : ''}` }, row.was),
76
- h('td', { class: `fx-change__now${row.num ? ' fx-num' : ''}` }, row.now))));
90
+ source ? cell('field', 'Amount', row.field) : h('th', { scope: 'row' }, row.field),
91
+ source ? cell('source', 'Source invoice', row.was, true) : h('td', { class: `fx-change__was${row.num ? ' fx-num' : ''}` }, row.was),
92
+ source ? cell('flow', 'Flow job lines', row.now, true) : h('td', { class: `fx-change__now${row.num ? ' fx-num' : ''}` }, row.now),
93
+ source ? cell('difference', 'Difference', row.difference, true) : null)));
77
94
  draw(false);
95
+ const table = h('table', { class: 'fx-change__table' },
96
+ h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, source ? 'Amount' : 'Field'),
97
+ h('th', { scope: 'col' }, source ? 'Source invoice' : 'Was'),
98
+ h('th', { scope: 'col' }, source ? 'Flow job lines' : `Now in ${system}`),
99
+ source ? h('th', { scope: 'col' }, 'Difference') : null)), body);
100
+ if (source) ui.cardRows(table, { areas: ['field field', 'source flow', 'difference difference'], columns: 'repeat(2, minmax(0, 1fr))' });
101
+ const inFlow = (href, label) => h('a', { class: 'fx-btn fx-btn--sm', href, onclick: (event) => ui.go(event) }, label);
102
+ const sourceSteps = source ? h('div', { class: 'fx-change__steps' },
103
+ h('p', { class: 'fx-change__note' }, `Source invoice: ${change.sourceInvoiceRef ?? 'Not supplied'}.${change.caseRef ? ` Case: ${change.caseRef}.` : ''}`),
104
+ h('p', {}, 'Flow cannot tell which amount is wrong.'),
105
+ h('p', { class: 'fx-change__note' }, 'Difference = Flow job lines minus source invoice. Do not divide a source invoice total between jobs.'),
106
+ source.unknownTaxLines.length ? h('p', {}, `Tax is unknown on ${source.unknownTaxLines.map((line) => `line ${line.position}${line.description ? ` (${line.description})` : ''}`).join(', ')}. Flow cannot calculate GST or the total.`) : null,
107
+ h('ol', {},
108
+ h('li', {}, change.link?.exact ? `Open the job in ${system}, then check its linked invoice.` : `Open ${system}${change.link ? ' with its link' : ' separately'}. Find ${change.caseRef ? `case ${change.caseRef}` : order ?? 'the linked job'}, then check the source invoice.`),
109
+ h('li', {}, 'Check each price, quantity, discount and tax value against the source facts.'),
110
+ h('li', {}, 'Correct only facts you can verify. Do not choose tax only to remove this difference.'),
111
+ h('li', {}, 'If a Flow mapping is wrong, correct it in Price codes or Price list.'),
112
+ h('li', {}, change.canRecheck === false ? 'Ask an administrator to restore the source connection, then return here.' : 'Return here and select Recheck. Flow reads the source again. It does not send this invoice.')),
113
+ h('div', { class: 'fx-change__acts' }, inFlow('/invoicing-settings/price-codes', 'Price codes'), inFlow('/price-list', 'Price list')),
114
+ change.canRecheck === false ? h('p', {}, `Flow cannot read this job from ${system}.`) : null,
115
+ h('details', {}, h('summary', {}, 'Saved invoice'),
116
+ h('p', {}, 'These saved Flow amounts do not change when you recheck.'),
117
+ h('dl', { class: 'fx-facts' }, [['Subtotal', 'subtotalCents'], ['GST', 'taxCents'], ['Total', 'totalCents']].flatMap(([label, key]) => [h('dt', {}, label), h('dd', {}, money(source.savedInvoice[key]))])),
118
+ change.seenAt ? h('p', { class: 'fx-change__note' }, `Last source report: ${new Date(change.seenAt).toLocaleString('en-AU')}.`) : null)) : null;
78
119
  const more = rows.length > FIRST_ROWS
79
120
  ? h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: (event) => { draw(true); event.currentTarget.remove(); } }, `Show all ${rows.length}`)
80
121
  : null;
81
- return applyPhoneRule(h('section', { class: 'fx-change', role: 'alert' },
122
+ return applyPhoneRule(h('section', { class: 'fx-change', role: 'alert', 'aria-label': source ? 'Source invoice comparison' : 'Job comparison' },
82
123
  h('div', { class: 'fx-change__head' },
83
124
  h('p', {}, h('strong', {}, 'Problem.'), ' ', changeSummary(change, rows.filter((row) => row.field !== 'Total').length)),
84
125
  h('div', { class: 'fx-change__acts' }, link, accept, recheck)),
85
- !change.link && order ? h('p', { class: 'fx-change__note' }, `Find ${order} in ${system} to fix it there.`) : null,
86
- rows.length ? h('div', { class: 'fx-change__scroll' }, h('table', { class: 'fx-change__table' },
87
- h('thead', {}, h('tr', {}, h('th', { scope: 'col' }, 'Field'), h('th', { scope: 'col' }, 'Was'), h('th', { scope: 'col' }, `Now in ${system}`))),
88
- body)) : null,
126
+ !source && !change.link && order ? h('p', { class: 'fx-change__note' }, `Find ${order} in ${system} to fix it there.`) : null,
127
+ rows.length ? h('div', { class: 'fx-change__scroll' }, table) : null,
128
+ sourceSteps,
89
129
  more), 'jobChangeBanner');
90
130
  }
91
131
 
@@ -114,9 +154,17 @@ export function jobChangeReview(ui, change, { money, canAccept = false, unsaved
114
154
  },
115
155
  // Reads this one job from the work system again, then every check runs.
116
156
  onRecheck: async () => {
157
+ if (unsaved()) {
158
+ toast('Save or discard your changes before Recheck.', 'danger');
159
+ return;
160
+ }
117
161
  try {
118
162
  const res = await flow(`/api/invoicing/jobs/${change.jobId}/recheck`, { method: 'POST', body: '{}' });
119
- toast(res.invoice?.jobChange ? `Still changed in ${system}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
163
+ const pending = Boolean(res.invoice?.jobChange || res.invoice?.problems?.length);
164
+ const cached = res.outcome === 'fresh';
165
+ toast(cached ? `No new source read. Try again in ${res.retryAfterSeconds ?? 10} seconds.`
166
+ : pending ? 'Still pending. Check the comparison and the remaining problems.'
167
+ : 'Source check complete. No invoice problem remains.', cached ? 'info' : pending ? 'danger' : 'success');
120
168
  if (res.invoice) await done(res.invoice);
121
169
  } catch (error) {
122
170
  toast(error.message, 'danger');
package/server/ui/jobs.js CHANGED
@@ -1136,7 +1136,7 @@ export async function renderJobs(main, ui) {
1136
1136
  ? link({ href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } },
1137
1137
  `${o.invoiceStatus === 'draft' ? 'Draft' : 'Invoice'} ${o.invoiceNumber ?? ''}`.trim() + (stage ? ` · ${INVOICE_STAGES[stage]}` : ''))
1138
1138
  : choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
1139
- : stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
1139
+ : stage === 'drafted' ? 'Not drafted' : stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
1140
1140
  const rest = found.filter((f) => f.go !== 'choose');
1141
1141
  // What to fix: one chip a problem, beside the stage, so the panel keeps
1142
1142
  // one layout with or without a fix. A chip is a toggle: a click opens
@@ -1181,7 +1181,7 @@ export async function renderJobs(main, ui) {
1181
1181
  }
1182
1182
  // Each fix's link: Flow's own invoice, the job in the work system, or
1183
1183
  // the Flow page where the fix is made (job-board.js GO).
1184
- function fixLink(go, tip = null) {
1184
+ function fixLink(go, tip = null, rule = null) {
1185
1185
  const button = (icon, text, attrs) => h(attrs.href ? 'a' : 'button', {
1186
1186
  class: 'fx-btn fx-btn--sm fx-btn--primary fx-task__fixgo', title: tip, ...(attrs.href ? {} : { type: 'button' }), ...attrs,
1187
1187
  }, lucide(h, icon), text);
@@ -1189,7 +1189,7 @@ export async function renderJobs(main, ui) {
1189
1189
  if ((go === 'review' || go === 'invoice') && o?.invoiceId) return inFlow('file-text', `/invoices/${o.invoiceId}`, 'Invoice');
1190
1190
  if (go === 'work' && job.link?.url) return button('external-link', system, { href: job.link.url, target: '_blank', rel: 'noopener' });
1191
1191
  if (go === 'price-list') return inFlow('tag', '/price-list', 'Price list');
1192
- if (go === 'invoicing-settings') return inFlow('list-checks', '/invoicing-settings', 'Rules');
1192
+ if (go === 'rule' && rule) return inFlow('list-checks', `/invoicing-settings/invoices?rule=${encodeURIComponent(rule)}`, 'Review rule');
1193
1193
  if (go === 'integrations' && ui.me?.person?.role === 'owner') return inFlow('plug', '/integrations', 'Integrations');
1194
1194
  return null;
1195
1195
  }
@@ -1230,7 +1230,7 @@ export async function renderJobs(main, ui) {
1230
1230
  return h('section', { class: 'fx-task__fixitem', 'aria-label': f.label }, reviewBody);
1231
1231
  }
1232
1232
  return h('section', { class: 'fx-task__fixitem', 'aria-label': f.label },
1233
- h('strong', {}, f.label), h('p', {}, f.text), f.todo ? h('p', { class: 'fx-dim' }, f.todo) : null, fixLink(f.go));
1233
+ h('strong', {}, f.label), h('p', {}, f.text), f.todo ? h('p', { class: 'fx-dim' }, f.todo) : null, fixLink(f.go, null, f.rule));
1234
1234
  }
1235
1235
  const sideButton = h('button', {
1236
1236
  class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Side panel', title: 'Side panel', 'aria-pressed': 'false',
@@ -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",
@@ -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';