@arcforgelabs/flow 2026.10.42 → 2026.10.43

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.
@@ -170,15 +170,30 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
170
170
  .fx-main { flex: 1; min-width: 0; max-width: 1180px; margin: 0 auto; padding: 32px clamp(16px, 4vw, 48px) 64px; display: grid; gap: 20px; align-content: start; }
171
171
  .fx-main:focus { outline: none; box-shadow: none; }
172
172
 
173
- /* Page header: title and one line, then the actions, primary last. */
174
- .fx-ph { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 16px; }
175
- .fx-ph__text { display: grid; gap: 6px; max-width: 64ch; }
173
+ /* Page header: the title only (no line under it), then the actions,
174
+ primary last. One height on every page, with or without a button. */
175
+ .fx-ph { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; min-height: 36px; }
176
+ .fx-ph__text { display: flex; align-items: center; gap: 10px; min-width: 0; }
176
177
  .fx-title {
177
178
  color: var(--t-strong);
178
- font: var(--t-display-weight) 28px/1.15 var(--t-display); letter-spacing: -0.015em;
179
+ font: var(--t-display-weight) 24px/1.15 var(--t-display); letter-spacing: -0.015em;
179
180
  }
181
+ .fx-ph__count { color: var(--t-dim); font-size: 14px; font-variant-numeric: tabular-nums; }
182
+ /* A record's facts beside its title (Reconcile: client · date · total). */
180
183
  .fx-ph__sub { color: var(--t-dim); }
181
- .fx-ph__actions { display: flex; gap: 8px; }
184
+ /* A docs link as a "?" icon beside a title (helpLink in app.js). */
185
+ .fx-help { display: inline-flex; vertical-align: middle; margin-left: 2px; padding: 4px; border-radius: var(--t-radius); color: var(--t-dim); }
186
+ .fx-help:hover, .fx-help:focus-visible { background: var(--fx-hover); color: var(--t-strong); }
187
+ .fx-help .fx-lucide { width: 16px; height: 16px; }
188
+ .fx-ph__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
189
+
190
+ /* The tip an icon-only control shows on hover and keyboard focus (tip.js). */
191
+ .fx-tip {
192
+ position: fixed; top: 0; left: 0; z-index: 1000; max-width: 260px; padding: 4px 8px;
193
+ border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong);
194
+ font: 500 12px/1.3 var(--t-body); pointer-events: none; white-space: nowrap;
195
+ box-shadow: var(--t-shadow), 0 4px 12px rgba(0, 0, 0, .12);
196
+ }
182
197
 
183
198
  /* ── Notices, pills, tags, toasts ──────────────────────────────────────── */
184
199
  .fx-notice {
@@ -287,10 +302,12 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
287
302
  .fx-chip { border: 1px solid var(--fx-line); border-radius: 999px; background: var(--t-paper); color: var(--t-text); }
288
303
  .fx-int-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
289
304
  .fx-int-bar .fx-search { margin: 0; }
305
+ .fx-int-bar .fx-row { flex: 1 1 auto; flex-wrap: nowrap; justify-content: flex-end; min-width: 0; }
306
+ .fx-int-bar .fx-row .fx-search { flex: 0 1 320px; width: auto; min-width: 0; }
307
+ .fx-int-bar .fx-row .fx-select { flex: none; }
290
308
  .fx-int-sections { display: grid; gap: 32px; }
291
309
  .fx-int-section-list { display: grid; gap: 16px; }
292
310
  .fx-int-heading { color: var(--t-strong); font: 600 19px/1.3 var(--t-body); }
293
- .fx-int-heading-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
294
311
  .fx-int-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
295
312
  .fx-int-card {
296
313
  display: grid; grid-template-rows: auto 1fr auto; gap: 14px; padding: 18px;
@@ -298,6 +315,7 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
298
315
  color: var(--t-text); text-decoration: none;
299
316
  transition: transform 140ms var(--fx-ease), box-shadow 140ms var(--fx-ease), border-color 140ms var(--fx-ease);
300
317
  }
318
+ .fx-int-card--bare { grid-template-rows: auto auto; }
301
319
  .fx-int-card:hover, .fx-int-card:focus-visible { transform: translateY(-2px); border-color: var(--t-brd-hi, var(--t-control)); box-shadow: 0 8px 24px rgba(0, 0, 0, .1); }
302
320
  .fx-int-card__head { display: flex; align-items: center; gap: 12px; }
303
321
  .fx-int-card__head h3 { flex: 1; color: var(--t-strong); font: 650 16px/1.25 var(--t-body); }
@@ -312,6 +330,7 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
312
330
  display: grid; place-items: center; flex: none; width: 44px; height: 44px; border-radius: 10px;
313
331
  background: #fff; box-shadow: inset 0 0 0 1px var(--fx-line);
314
332
  }
333
+ .fx-logo--icon { background: var(--t-accent-bg); color: var(--t-accent); box-shadow: none; }
315
334
  .fx-logo img { width: 28px; height: 28px; object-fit: contain; }
316
335
  .fx-logo--lg { width: 52px; height: 52px; }
317
336
  .fx-logo--lg img { width: 34px; height: 34px; }
@@ -473,7 +492,8 @@ a.fx-strong:hover { text-decoration: underline; }
473
492
  }
474
493
 
475
494
  /* ── Jobs board ─────────────────────────────────────────────────────────── */
476
- .fx-main--wide { max-width: none; }
495
+ /* Every page shares .fx-main's width; only the board's lanes use the whole window. */
496
+ .fx-main--board { max-width: none; }
477
497
  .fx-main > * { min-width: 0; }
478
498
  .fx-toolbar--plain { border-bottom: 0; align-items: center; }
479
499
  .fx-toolbar--plain .fx-search { margin-bottom: 0; }
@@ -674,7 +694,14 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
674
694
  the activity at the side. It stacks on a phone. */
675
695
  .fx-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--t-dim); overflow-wrap: anywhere; }
676
696
  .fx-dialog.fx-task { width: min(1040px, calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow-y: auto; }
677
- .fx-task__grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; }
697
+ .fx-task__grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; }
698
+ /* No sidecar: the main area takes the room. Review is wider for its table. */
699
+ .fx-task__grid:has(> .fx-sidecar[hidden]) { grid-template-columns: minmax(0, 1fr); }
700
+ .fx-task__grid:has(> .fx-sidecar [data-tab="review"][aria-selected="true"]) { grid-template-columns: minmax(0, 1fr) 400px; }
701
+ .fx-task__side { background: var(--t-s1); }
702
+ .fx-task__tools { display: flex; align-items: center; gap: 4px; }
703
+ .fx-task__tools .fx-lucide { width: 16px; height: 16px; }
704
+ .fx-task__tools [aria-pressed="true"] { background: var(--fx-hover); color: var(--t-strong); }
678
705
  .fx-task__main { min-width: 0; padding: 20px 28px 28px; }
679
706
  .fx-task__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
680
707
  .fx-task__crumb { display: flex; flex-wrap: wrap; gap: 6px; color: var(--t-dim); font-size: 12.5px; }
@@ -689,9 +716,14 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
689
716
  .fx-task__link { color: var(--t-accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--t-accent) 40%, transparent); }
690
717
  .fx-task__link:hover { border-bottom-color: currentColor; }
691
718
  .fx-task__fix { scroll-margin-top: 16px; margin-top: 22px; border: 1px solid var(--t-warn-brd, var(--t-warn)); border-radius: var(--t-radius); background: var(--t-warn-bg); }
692
- .fx-task__fix h3, .fx-task__lines h3, .fx-task__activity h3 { margin: 0; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
719
+ .fx-task__fix h3, .fx-task__lines h3 { margin: 0; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
693
720
  .fx-task__fix h3 { padding: 12px 14px 4px; color: var(--t-warn); }
694
- .fx-task__fixrow { padding: 9px 14px; border-top: 1px solid var(--fx-line-soft); color: var(--t-strong); font-size: 13.5px; }
721
+ .fx-task__fixrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 9px 14px; border-top: 1px solid var(--fx-line-soft); color: var(--t-strong); font-size: 13.5px; }
722
+ .fx-task__fixrow > strong { font-weight: 600; }
723
+ .fx-task__fixrow > span { flex: 1 1 160px; color: var(--t-text); }
724
+ .fx-task__linkbtn { all: unset; cursor: pointer; color: var(--t-accent); border-bottom: 1px solid color-mix(in srgb, var(--t-accent) 40%, transparent); }
725
+ .fx-task__linkbtn:hover { border-bottom-color: currentColor; }
726
+ .fx-task__linkbtn:focus-visible { box-shadow: var(--fx-focus); }
695
727
  .fx-task__fixrow:first-of-type { border-top: 0; }
696
728
  .fx-task__choose { scroll-margin-top: 16px; margin-top: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 14px; border: 1px solid var(--t-warn-brd, var(--t-warn)); border-radius: var(--t-radius); background: var(--t-warn-bg); color: var(--t-strong); font-size: 13.5px; }
697
729
  .fx-task__choose > svg { flex: none; width: 20px; height: 20px; color: var(--t-warn); }
@@ -758,8 +790,9 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
758
790
  .fx-task__miss { color: var(--t-warn); }
759
791
  .fx-task__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; }
760
792
  .fx-task__actions:empty { display: none; }
761
- .fx-task__activity { min-width: 0; padding: 24px 20px; border-left: 1px solid var(--fx-line); background: var(--t-s1); }
762
- .fx-task__activity h3 { margin-bottom: 14px; }
793
+ .fx-task__activity { min-width: 0; }
794
+ /* Review in the sidecar: the change banner, without its own box. */
795
+ .fx-task__review .fx-change { margin: 0; padding: 0; border: 0; background: none; }
763
796
  .fx-task__activity ol { margin: 0; padding: 0; list-style: none; }
764
797
  .fx-task__activity li { position: relative; padding: 0 0 16px 18px; color: var(--t-text); font-size: 13px; }
765
798
  .fx-task__activity li::before { content: ""; position: absolute; left: 3px; top: 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--t-mute); }
@@ -771,7 +804,8 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
771
804
  .fx-task__grid { grid-template-columns: minmax(0, 1fr); }
772
805
  .fx-task__main { padding: 16px; }
773
806
  .fx-task__fields { grid-template-columns: minmax(0, 1fr); }
774
- .fx-task__activity { padding: 16px; border-top: 1px solid var(--fx-line); border-left: 0; }
807
+ .fx-task__grid:has(> .fx-sidecar [data-tab="review"][aria-selected="true"]) { grid-template-columns: minmax(0, 1fr); }
808
+ .fx-task__side { border-top: 1px solid var(--fx-line); border-left: 0; }
775
809
  }
776
810
 
777
811
  /* ── Dialog ─────────────────────────────────────────────────────────────── */
@@ -898,8 +932,13 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
898
932
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .15);
899
933
  }
900
934
  .fx-swatch[aria-checked="true"] { box-shadow: 0 0 0 2px var(--t-paper), 0 0 0 4px var(--t-text); }
901
- .fx-swatch--default { overflow: hidden; background: var(--t-paper); }
902
- .fx-swatch--default span { display: block; width: 100%; height: 100%; }
935
+ /* The default accent: a pill that says Default, before the colours. */
936
+ .fx-swatch--default {
937
+ display: inline-flex; align-items: center; gap: 6px; width: auto; padding: 0 10px 0 4px; border-radius: 999px;
938
+ background: var(--t-paper); color: var(--t-text); font: 600 12.5px/1 var(--t-body); box-shadow: inset 0 0 0 1px var(--t-control);
939
+ }
940
+ .fx-swatch--default[aria-checked="true"] { box-shadow: inset 0 0 0 1px var(--t-control), 0 0 0 2px var(--t-paper), 0 0 0 4px var(--t-text); }
941
+ .fx-swatch__dot { flex: none; width: 22px; height: 22px; border-radius: 50%; }
903
942
  .fx-swatch-custom { display: inline-flex; align-items: center; gap: 8px; color: var(--t-dim); font-size: 13px; font-weight: 600; }
904
943
  .fx-swatch-custom input { width: 34px; height: 30px; padding: 0; border: 1px solid var(--t-control); border-radius: var(--t-radius); background: var(--t-paper); cursor: pointer; }
905
944
 
@@ -946,7 +985,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
946
985
  /* ── Phones ─────────────────────────────────────────────────────────────── */
947
986
  @media (max-width: 640px) {
948
987
  .fx-main { padding-top: 20px; }
949
- .fx-title { font-size: 24px; }
988
+ .fx-title { font-size: 22px; }
950
989
  .fx-table thead { display: none; }
951
990
  .fx-table, .fx-table tbody, .fx-table tr, .fx-table td { display: block; width: 100%; }
952
991
  .fx-table tr { padding: 12px 14px; border-bottom: 1px solid var(--fx-line-soft); }
@@ -955,7 +994,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
955
994
  .fx-table tbody tr:hover td { background: none; }
956
995
  .fx-table__actions { text-align: left; }
957
996
  .fx-table__actions .fx-row--end { justify-content: flex-start; }
958
- .fx-ph__actions, .fx-ph__actions .fx-btn { width: 100%; }
959
997
  /* People: the name with its actions (⋯, Approve) on one line, then the
960
998
  role select full width, then the status pill when there is one. */
961
999
  .fx-people tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px 10px; }
@@ -1118,10 +1156,22 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1118
1156
  in from the right of the manager and leaves the table usable. */
1119
1157
  .fx-cfm__card { position: relative; }
1120
1158
  .fx-cfm__card:has(> .fx-cfs:not([hidden])) { min-height: min(640px, 100vh); }
1121
- .fx-cfs { position: absolute; inset: 0 0 0 auto; z-index: 5; display: flex; width: min(420px, 100%); border-left: 1px solid var(--fx-line); background: var(--t-paper); color: var(--t-text); box-shadow: -16px 0 40px rgba(0, 0, 0, .22); animation: fx-cfs-in .18s var(--fx-ease); }
1122
- .fx-cfs[hidden] { display: none; }
1123
- @keyframes fx-cfs-in { from { transform: translateX(24px); opacity: 0; } }
1124
- @media (prefers-reduced-motion: reduce) { .fx-cfs { animation: none; } }
1159
+ /* The shared sidecar (sidecar.js): a right column, or over the page with
1160
+ --over. Tabs are an icon and one word; × closes it. */
1161
+ .fx-sidecar { display: flex; flex-direction: column; min-width: 0; border-left: 1px solid var(--fx-line); background: var(--t-paper); color: var(--t-text); }
1162
+ .fx-sidecar[hidden] { display: none; }
1163
+ .fx-sidecar--over { position: absolute; inset: 0 0 0 auto; z-index: 5; width: min(420px, 100%); box-shadow: -16px 0 40px rgba(0, 0, 0, .22); animation: fx-sidecar-in .18s var(--fx-ease); }
1164
+ @keyframes fx-sidecar-in { from { transform: translateX(24px); opacity: 0; } }
1165
+ @media (prefers-reduced-motion: reduce) { .fx-sidecar--over { animation: none; } }
1166
+ .fx-sidecar__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 52px; padding: 0 12px 0 8px; border-bottom: 1px solid var(--fx-line-soft); }
1167
+ .fx-sidecar__tabs { display: flex; gap: 2px; min-width: 0; }
1168
+ .fx-sidecar__tab { all: unset; cursor: pointer; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 10px; border-radius: var(--t-radius); color: var(--t-dim); font-size: 13px; font-weight: 500; }
1169
+ .fx-sidecar__tab .fx-lucide { width: 15px; height: 15px; }
1170
+ .fx-sidecar__tab:hover { background: var(--fx-hover); color: var(--t-strong); }
1171
+ .fx-sidecar__tab[aria-selected="true"] { background: var(--fx-hover); color: var(--t-strong); }
1172
+ .fx-sidecar__tab:focus-visible { box-shadow: var(--fx-focus); }
1173
+ .fx-sidecar__mark { width: 6px; height: 6px; border-radius: 50%; background: var(--t-warn); }
1174
+ .fx-sidecar__body { flex: 1; min-height: 0; overflow-y: auto; padding: 16px; }
1125
1175
  .fx-cfs__form { position: sticky; top: 0; display: flex; flex-direction: column; width: 100%; height: min(100%, 100vh); max-height: 100vh; min-height: 0; }
1126
1176
  .fx-cfs__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 52px; padding: 0 12px 0 16px; border-bottom: 1px solid var(--fx-line-soft); }
1127
1177
  .fx-cfs__typename, .fx-cfs__typepick { display: inline-flex; align-items: center; gap: 8px; color: var(--t-text); font-weight: 500; font-size: 14px; }
@@ -1129,9 +1179,9 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1129
1179
  .fx-cfs__typepick:hover, .fx-cfs__typepick:focus-within { background: var(--fx-hover); }
1130
1180
  .fx-cfs__typepick .fx-lucide { width: 15px; height: 15px; }
1131
1181
  .fx-cfs__type { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
1132
- .fx-cfs__close, .fx-cfs__delete, .fx-cfs__tool { all: unset; cursor: pointer; display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 6px; color: var(--t-dim); }
1133
- .fx-cfs__close:hover, .fx-cfs__tool:hover:not(:disabled) { background: var(--fx-hover); color: var(--t-strong); }
1134
- .fx-cfs__close:focus-visible, .fx-cfs__delete:focus-visible, .fx-cfs__tool:focus-visible { box-shadow: var(--fx-focus); }
1182
+ .fx-sidecar__close, .fx-cfs__delete, .fx-cfs__tool { all: unset; cursor: pointer; display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 6px; color: var(--t-dim); }
1183
+ .fx-sidecar__close:hover, .fx-cfs__tool:hover:not(:disabled) { background: var(--fx-hover); color: var(--t-strong); }
1184
+ .fx-sidecar__close:focus-visible, .fx-cfs__delete:focus-visible, .fx-cfs__tool:focus-visible { box-shadow: var(--fx-focus); }
1135
1185
  .fx-cfs__body { flex: 1; min-height: 0; overflow-y: auto; display: grid; align-content: start; gap: 0; }
1136
1186
  .fx-cfs__section { display: grid; gap: 8px; padding: 14px 16px 4px; }
1137
1187
  .fx-cfs__section:last-child { margin-top: 14px; padding-top: 16px; border-top: 1px solid var(--fx-line-soft); }
@@ -1250,13 +1300,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1250
1300
  .fx-pager__step:hover:not(:disabled) { background: var(--fx-hover); border-color: var(--fx-line); }
1251
1301
  .fx-pager__step:disabled { opacity: 0.35; cursor: default; }
1252
1302
 
1253
- /* New invoice from a job: a quiet strip above the form, and a searchable
1254
- list of jobs with no invoice. */
1255
- .fx-fromjob {
1256
- display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px;
1257
- padding: 12px 16px; border: 1px dashed var(--fx-line); border-radius: var(--t-radius);
1258
- }
1259
- .fx-fromjob__text { display: grid; gap: 2px; font-size: 14px; }
1303
+ /* New invoice from a job: a searchable list of jobs with no invoice. */
1260
1304
  .fx-dialog:has(.fx-pick__body) { width: min(620px, calc(100vw - 32px)); }
1261
1305
  .fx-pick__body { display: grid; gap: 12px; }
1262
1306
  .fx-pick { display: grid; max-height: min(420px, 55vh); overflow-y: auto; border: 1px solid var(--fx-line); border-radius: var(--t-radius); }
@@ -1300,12 +1344,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1300
1344
  .fx-bulk__act[data-tone="danger"] { color: var(--t-danger); }
1301
1345
  .fx-bulk__act[data-tone="danger"]:hover { background: var(--t-danger-bg); }
1302
1346
  .fx-bulk__sep { flex: none; width: 1px; height: 18px; margin: 0 4px; background: var(--fx-line); }
1303
- /* An icon-only action says its name on hover, in a tip above it. */
1304
- .fx-bulk [data-tip]:hover::after, .fx-bulk [data-tip]:focus-visible::after {
1305
- content: attr(data-tip); position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
1306
- padding: 4px 8px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong);
1307
- font: 500 12px/1.2 var(--t-body); pointer-events: none; box-shadow: var(--t-shadow), 0 4px 12px rgba(0, 0, 0, .12);
1308
- }
1309
1347
  /* More: a compact list, one row per action, like a dropdown. */
1310
1348
  .fx-pop.fx-bulk__more { width: 248px; min-width: 0; padding: 4px; }
1311
1349
  .fx-bulk__search { width: 100%; height: 30px; margin-bottom: 2px; padding: 4px 8px; font-size: 13px; }
@@ -1370,6 +1408,8 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1370
1408
 
1371
1409
  /* A list's own search (view-bar.js findBox): an icon that opens into a box. */
1372
1410
  .fx-find { display: inline-flex; align-items: center; }
1411
+ /* On a tab bar the icon sits level with the tab names, not on the line. */
1412
+ .fx-toolbar > .fx-find { align-self: center; }
1373
1413
  .fx-find__button { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; }
1374
1414
  .fx-find__button .fx-lucide { width: 15px; height: 15px; }
1375
1415
  .fx-find__input { display: none; width: min(240px, 60vw); height: 32px; padding: 0 10px; }
@@ -18,9 +18,11 @@ export function sendModeNote(to, sendMode, what = 'each invoice') {
18
18
  return text.join(' ');
19
19
  }
20
20
 
21
- // The line under a draft's title, one fact per sentence.
22
- export function draftNote(to, invoice, ready) {
23
- if (!to) return ready ? 'It has no problem. Approve gives it a number.' : 'Fix the problem, then approve it.';
24
- if (!ready) return `Fix the problem, then send it to ${to}.`;
25
- return sendModeNote(to, invoice?.sendMode, 'this invoice');
21
+ // A warning above a draft, only when its Send cannot work: the accounting
22
+ // system is off or takes no invoices. Otherwise nothing: the problems show
23
+ // on their own, and the Send dialog says what Send does.
24
+ export function draftNote(to, invoice) {
25
+ const sendMode = invoice?.sendMode;
26
+ if (!to || !sendMode || (sendMode.connected && sendMode.writesAllowed)) return null;
27
+ return sendModeNote(to, sendMode);
26
28
  }
@@ -1,15 +1,14 @@
1
- // Settings, Invoicing (/invoicing-settings): payment terms, the draft
2
- // automation, and the lab's own rules a ready job passes before Flow drafts
3
- // its invoice (invoice-rules.js, docs/INVOICE_RULES.md). Invoicing needs no integration: how an invoice
4
- // goes to Xero is on the Xero integration page (sendModeSection).
5
- // Draft when ready is an automation (server/core/automations.js); the
6
- // Seazona integration page holds the rule that marks a job ready.
7
- // One row for each setting: its name, one short line, and its control. A
8
- // change saves at once. Everyone can read them; an owner or admin changes
9
- // them. The full rules are in docs/INVOICING_RULES.md. app.js passes in its
10
- // shared pieces as `ui`.
1
+ // Settings, Rules (/invoicing-settings): a card for each part, like the
2
+ // Integrations page, and one page for each (/invoicing-settings/<key>).
3
+ // Drafts holds payment terms and the Reference; Price codes the lines with no
4
+ // price item (line-mappings.js); Invoices the checks a ready job passes
5
+ // before Flow drafts its invoice (invoice-rules.js, docs/INVOICE_RULES.md).
6
+ // Automations have their own page (automations.js).
7
+ // How an invoice goes to Xero is on the Xero integration page
8
+ // (sendModeSection). A change saves at once. Everyone can read them; an owner
9
+ // or admin changes them. The full rules are in docs/INVOICING_RULES.md.
10
+ // app.js passes in its shared pieces as `ui`.
11
11
 
12
- import { automationSection } from '/assets/ui/automations.js';
13
12
  import { rulesSection } from '/assets/ui/invoice-rules.js';
14
13
  import { lineMappingSection } from '/assets/ui/line-mappings.js';
15
14
 
@@ -20,19 +19,51 @@ const SEND_MODES = [
20
19
  { value: 'approve_and_email', label: 'Approved and emailed' },
21
20
  ];
22
21
 
23
- export async function renderInvoicingSettings(main, ui) {
24
- const { h, flow, toast, notice, pageHeader, me } = ui;
25
- const host = h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, h('div', { class: 'fx-skeleton' }));
26
- main.append(pageHeader('Invoice rules', { subtitle: 'The rules Flow uses to draft each invoice.' }), host);
27
- let body;
28
- try {
29
- body = await flow('/api/invoicing/settings');
30
- } catch (error) {
31
- host.replaceChildren(notice(error.message, 'danger'));
22
+ const onCount = (list) => ({ label: `${list.filter((x) => x.enabled).length} on`, tone: list.some((x) => x.enabled) ? 'success' : 'mute' });
23
+
24
+ // Each card: its page, its mark, and its state once the data is in. No
25
+ // blurb: the name and the state say enough.
26
+ const PARTS = [
27
+ { key: 'drafts', title: 'Drafts', mark: 'draft', mount: (ui) => draftsSection(ui) },
28
+ { key: 'price-codes', title: 'Price codes', mark: 'tag', mount: (ui) => lineMappingSection(ui),
29
+ summary: async (flow) => {
30
+ const { labels } = await flow('/api/invoicing/line-labels');
31
+ const waiting = labels.filter((l) => l.unmapped > 0).length;
32
+ return waiting ? { status: { label: `${waiting} wait`, tone: 'warn' }, action: 'Fix' } : { status: { label: 'All mapped', tone: 'success' } };
33
+ } },
34
+ { key: 'invoices', title: 'Invoices', mark: 'rules', mount: (ui) => rulesSection(ui, 'Invoices'),
35
+ summary: async (flow) => ({ status: onCount((await flow('/api/invoicing/rules')).rules) }) },
36
+ ];
37
+
38
+ export function renderInvoicingSettings(main, ui, id) {
39
+ const { h, flow, notice, pageHeader, tileCard, crumbs } = ui;
40
+ if (id) {
41
+ const part = PARTS.find((p) => p.key === id);
42
+ main.append(crumbs(['/invoicing-settings', 'Rules'], part?.title ?? 'Not found'),
43
+ part ? h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, part.mount(ui)) : notice('No such part of Rules.', 'danger'));
32
44
  return;
33
45
  }
46
+ const grid = h('div', { class: 'fx-int-grid' });
47
+ main.append(pageHeader('Rules'), grid);
48
+ // Each card shows at once and fills in its state when its data comes back.
49
+ for (const part of PARTS) {
50
+ const href = `/invoicing-settings/${part.key}`;
51
+ const slot = tileCard({ href, mark: part.mark, title: part.title });
52
+ grid.append(slot);
53
+ if (part.summary) {
54
+ part.summary(flow)
55
+ .then((more) => slot.replaceWith(tileCard({ href, mark: part.mark, title: part.title, ...more })))
56
+ .catch(() => {});
57
+ }
58
+ }
59
+ }
60
+
61
+ // Payment terms and the Reference template.
62
+ function draftsSection(ui) {
63
+ const { h, flow, toast, notice, me } = ui;
34
64
  const disabled = !['owner', 'admin'].includes(me?.person.role);
35
- let saved = body.settings;
65
+ const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Drafts'), h('div', { class: 'fx-skeleton' }));
66
+ let saved;
36
67
 
37
68
  async function save(changes, control) {
38
69
  control.disabled = true;
@@ -60,19 +91,23 @@ export async function renderInvoicingSettings(main, ui) {
60
91
  });
61
92
  return input;
62
93
  };
63
- const days = (key, label) => box(key, label, { type: 'number', read: Number });
64
94
 
65
- const section = (title, ...rows) => h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), ...rows);
66
- host.replaceChildren(...[
67
- disabled ? notice('Only an owner or admin can change these.', 'info') : null,
68
- section('Drafts',
69
- row('Payment terms', 'Days from invoice to due date.', days('paymentTermsDays', 'Payment terms in days')),
95
+ (async () => {
96
+ try {
97
+ saved = (await flow('/api/invoicing/settings')).settings;
98
+ } catch (error) {
99
+ section.replaceChildren(h('h2', { class: 'fx-set__title' }, 'Drafts'), notice(error.message, 'danger'));
100
+ return;
101
+ }
102
+ section.replaceChildren(...[
103
+ h('h2', { class: 'fx-set__title' }, 'Drafts'),
104
+ disabled ? notice('Only an owner or admin can change these.', 'info') : null,
105
+ row('Payment terms', 'Days from invoice to due date.', box('paymentTermsDays', 'Payment terms in days', { type: 'number', read: Number })),
70
106
  row('Reference', 'The Reference on each invoice. Use {practitioner} and {patient}.',
71
- box('referenceTemplate', 'Reference rule', { placeholder: '{practitioner} - {patient}' }))),
72
- lineMappingSection(ui),
73
- automationSection(ui, 'invoicing', 'Automations'),
74
- rulesSection(ui),
75
- ].filter(Boolean));
107
+ box('referenceTemplate', 'Reference rule', { placeholder: '{practitioner} - {patient}' })),
108
+ ].filter(Boolean));
109
+ })();
110
+ return section;
76
111
  }
77
112
 
78
113
  // The Xero integration page's "Sending" section: how a checked invoice goes to
@@ -82,11 +117,10 @@ export function sendModeSection(ui) {
82
117
  const disabled = !['owner', 'admin'].includes(me?.person.role);
83
118
  const select = h('select', { class: 'fx-select', 'aria-label': 'Send to Xero as', disabled },
84
119
  SEND_MODES.map((m) => h('option', { value: m.value }, m.label)));
85
- const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Sending'),
120
+ const section = h('section', { class: 'fx-set' }, ui.sectionTitle('Sending', { href: DOCS, label: 'How sending works' }),
86
121
  h('div', { class: 'fx-set__row' },
87
122
  h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, 'Send to Xero as'), h('span', { class: 'fx-set__hint' }, 'Draft: approve in Xero. Approved: Xero emails it.')),
88
- h('div', { class: 'fx-set__control' }, select)),
89
- h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How sending works')));
123
+ h('div', { class: 'fx-set__control' }, select)));
90
124
  let saved = null;
91
125
  (async () => {
92
126
  try {
@@ -9,7 +9,7 @@ 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
11
  import { caseNumber } from '/assets/ui/job-board.js';
12
- import { jobChangeBanner } from '/assets/ui/job-change.js';
12
+ import { jobChangeReview } from '/assets/ui/job-change.js';
13
13
  import { findBox } from '/assets/ui/view-bar.js';
14
14
 
15
15
  // The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
@@ -836,15 +836,12 @@ function draftView(invoice, ui, context, redraw) {
836
836
  dueDate.addEventListener('change', syncIssue);
837
837
  syncIssue();
838
838
 
839
- // A new invoice can start from a job that has no invoice yet: a quiet
840
- // strip above the form says so. Flow drafts it the way the invoicing rules
841
- // do (client, Reference, lines) and links it to the job. An invoice with
842
- // no job is fine too.
843
- const fromJob = isNew && context.canIssue ? h('section', { class: 'fx-fromjob' },
844
- h('div', { class: 'fx-fromjob__text' },
845
- h('span', { class: 'fx-strong' }, 'Billing a job?'),
846
- h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
847
- h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
839
+ // A new invoice can start from a job that has no invoice yet: a button in
840
+ // the header. Flow drafts it the way the invoicing rules do (client,
841
+ // Reference, lines) and links it to the job. An invoice with no job is
842
+ // fine too.
843
+ const fromJob = isNew && context.canIssue
844
+ ? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => pickJob(ui) }, 'From a job') : null;
848
845
 
849
846
  // A draft with no problem is in Ready by itself; one with a problem is
850
847
  // Pending. A changed job is fixed at its source or accepted here.
@@ -853,47 +850,18 @@ function draftView(invoice, ui, context, redraw) {
853
850
  // changed. Other problems stay one line.
854
851
  const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
855
852
  const problems = [
856
- jobChange ? jobChangeBanner(ui, jobChange, {
857
- money: formatCents,
858
- // Accepting changes the draft for the business: owners and admins only.
859
- onAccept: !context.canIssue ? null : async () => {
860
- const system = jobChange.systemName;
861
- const lost = [jobChange.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, changed() ? 'Unsaved changes are lost.' : null].filter(Boolean);
862
- if (lost.length) {
863
- const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
864
- if (!ok) return;
865
- }
866
- try {
867
- const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
868
- toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
869
- redraw(res.invoice);
870
- } catch (error) {
871
- toast(error.message, 'danger');
872
- }
873
- },
874
- // Reads this one job from the work system again, then every check runs.
875
- onRecheck: async () => {
876
- try {
877
- const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/recheck`, { method: 'POST', body: '{}' });
878
- toast(res.invoice?.jobChange ? `Still changed in ${jobChange.systemName}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
879
- if (res.invoice) redraw(res.invoice);
880
- } catch (error) {
881
- toast(error.message, 'danger');
882
- }
883
- },
884
- }) : null,
853
+ // Accepting changes the draft for the business: owners and admins only.
854
+ jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
885
855
  otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
886
856
  ];
887
857
 
888
858
  return [
889
859
  navBar(ui, invoice),
890
- h('header', { class: 'fx-ph' },
891
- h('div', { class: 'fx-ph__text' },
892
- h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice'),
893
- h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
894
- h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton)),
860
+ ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
861
+ actions: [fromJob, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton].filter(Boolean),
862
+ }),
863
+ draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
895
864
  ...problems,
896
- fromJob,
897
865
  h('div', { class: 'fx-invoice__grid' },
898
866
  h('div', { class: 'fx-invoice__main' },
899
867
  h('section', { class: 'fx-card fx-invoice__head' },
@@ -999,10 +967,8 @@ function issuedView(invoice, ui, context, redraw) {
999
967
  navBar(ui, invoice),
1000
968
  h('header', { class: 'fx-ph' },
1001
969
  h('div', { class: 'fx-ph__text' },
1002
- h('div', { class: 'fx-row' },
1003
- h('h1', { class: 'fx-title' }, invoice.number),
1004
- h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
1005
- h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · ${showDate(invoice.issueDate)}`)),
970
+ h('h1', { class: 'fx-title' }, invoice.number),
971
+ h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
1006
972
  h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
1007
973
  h('div', { class: 'fx-invoice__grid' },
1008
974
  h('div', { class: 'fx-invoice__main' },
@@ -1072,7 +1038,7 @@ function xeroCard(invoice, ui, context, status, reload) {
1072
1038
  }
1073
1039
 
1074
1040
  // Send to Xero (POST /api/xero/invoices/push). The customer's send mode
1075
- // (Settings, Invoice rules) decides what Xero gets: a draft that a person approves
1041
+ // (Settings, Rules) decides what Xero gets: a draft that a person approves
1076
1042
  // there, or an approved invoice that Xero emails to the customer.
1077
1043
  function sendToXero(invoice, ui, context) {
1078
1044
  const { h, flow, toast } = ui;
@@ -93,7 +93,10 @@ function reasonFix(reason) {
93
93
  if (reason === 'the work system report is out of date') {
94
94
  return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.', go: 'integrations' };
95
95
  }
96
- if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Open the invoice to see what changed. Fix the job in the work system, or adopt its changes on the draft.', 'invoice');
96
+ // Review opens the job's own sidecar: what changed, and Accept change.
97
+ if (reason === 'the job changed after its invoice was made') {
98
+ return { label: 'Job changed', text: 'Changed after the draft was made.', todo: 'Fix it in the work system, or accept the change.', go: 'review' };
99
+ }
97
100
  if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.', 'work');
98
101
  if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.', 'work');
99
102
  // The matcher found invoices that may be this job's, but no exact link.
@@ -134,27 +137,36 @@ function draftFix(problem) {
134
137
  }
135
138
 
136
139
  // Where a fix's link goes: Flow's invoice, the job in the work system, a
137
- // Flow settings page, or `choose`: Make invoice and Link invoice buttons
138
- // (jobs.js draws them).
139
- export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'choose'];
140
+ // Flow settings page, `review`: the job's Review sidecar, or `choose`: Make
141
+ // invoice and Link invoice buttons (jobs.js draws them).
142
+ export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'review', 'choose'];
140
143
 
141
144
  // What a person must fix on a job, in order: [{ label, text, todo, go }];
142
145
  // `todo` is null when the buttons are the instruction.
143
- // Empty unless invoiceStage is Problem.
146
+ // Empty unless invoiceStage is Problem. One fact is one fix: a job in review
147
+ // already says why, so its draft's copy of that hold (sending.js,
148
+ // `job_review`) is left out, and two fixes with the same words show once.
144
149
  export function fixes(o) {
145
150
  if (invoiceStage(o) !== 'problem') return [];
146
151
  const found = [];
147
152
  if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
148
153
  if (o.view === 'ready') {
149
154
  found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
150
- label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Invoice rules.', go: 'invoicing-settings',
155
+ label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Rules.', go: 'invoicing-settings',
151
156
  })));
152
157
  }
153
158
  if (o.invoiceStatus === 'void') {
154
159
  found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
155
160
  }
156
- found.push(...(o.invoiceProblems ?? []).map((p) => draftFix(p)));
157
- return found;
161
+ const held = o.view === 'review_required' && o.holdState === 'review';
162
+ found.push(...(o.invoiceProblems ?? []).filter((p) => !(held && p.code === 'job_review')).map((p) => draftFix(p)));
163
+ const seen = new Set();
164
+ return found.filter((f) => {
165
+ const key = `${f.label}\u0000${f.text}`;
166
+ if (seen.has(key)) return false;
167
+ seen.add(key);
168
+ return true;
169
+ });
158
170
  }
159
171
 
160
172
  // The short words for a job's problems: the first label, and "+N" for more.
@@ -87,3 +87,39 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
87
87
  body)) : null,
88
88
  more);
89
89
  }
90
+
91
+ // The banner with Accept change and the recheck wired, for the invoice page
92
+ // and a job's Review sidecar (jobs.js). `canAccept`: the person may change
93
+ // drafts (owners and admins). `unsaved()`: edits on the page that Accept
94
+ // would lose. `done(invoice)` hears the invoice after Accept or a recheck.
95
+ export function jobChangeReview(ui, change, { money, canAccept = false, unsaved = () => false, done = () => {} }) {
96
+ const { h, flow, toast } = ui;
97
+ const system = change.systemName;
98
+ return jobChangeBanner(ui, change, {
99
+ money,
100
+ onAccept: !canAccept ? null : async () => {
101
+ const lost = [change.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, unsaved() ? 'Unsaved changes are lost.' : null].filter(Boolean);
102
+ if (lost.length) {
103
+ const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
104
+ if (!ok) return;
105
+ }
106
+ try {
107
+ const res = await flow(`/api/invoicing/jobs/${change.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
108
+ toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
109
+ await done(res.invoice);
110
+ } catch (error) {
111
+ toast(error.message, 'danger');
112
+ }
113
+ },
114
+ // Reads this one job from the work system again, then every check runs.
115
+ onRecheck: async () => {
116
+ try {
117
+ const res = await flow(`/api/invoicing/jobs/${change.jobId}/recheck`, { method: 'POST', body: '{}' });
118
+ toast(res.invoice?.jobChange ? `Still changed in ${system}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
119
+ if (res.invoice) await done(res.invoice);
120
+ } catch (error) {
121
+ toast(error.message, 'danger');
122
+ }
123
+ },
124
+ });
125
+ }