@arcforgelabs/flow 2026.10.44 → 2026.10.46

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.
@@ -11,6 +11,9 @@
11
11
  edge, as ClickUp and HubSpot do; the title, toolbar and content start at
12
12
  one place on every page. */
13
13
  --fx-gutter: clamp(16px, 2vw, 24px);
14
+ /* The height of every control in a list's toolbar: chips, view tabs,
15
+ search, Board/List and the page's button. */
16
+ --fx-control-h: 32px;
14
17
  --fx-line-soft: color-mix(in srgb, var(--t-text) 7%, transparent);
15
18
  --fx-hover: color-mix(in srgb, var(--t-text) 5%, transparent);
16
19
  --fx-focus: 0 0 0 3px color-mix(in srgb, var(--t-ring) 40%, transparent);
@@ -141,17 +144,16 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
141
144
  .fx-toggle__mark { display: none; }
142
145
  .fx-toggle__mark .fx-mark { display: block; }
143
146
  .fx-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; overflow-y: auto; }
144
- .fx-nav__group { padding: 16px 10px 6px; color: var(--t-dim); font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body); letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case); }
145
- .fx-nav__group:first-child { padding-top: 4px; }
146
- .fx-nav a, .fx-nav__report {
147
+ .fx-nav a, .fx-nav__report, .fx-nav__more {
147
148
  display: flex; align-items: center; gap: 10px;
148
149
  padding: 8px 10px; border-radius: var(--t-radius);
149
150
  color: var(--t-text); font-weight: 500; font-size: 14px; text-decoration: none; white-space: nowrap;
150
151
  }
151
- .fx-nav a:hover, .fx-nav__report:hover { color: var(--t-strong); background: var(--fx-hover); }
152
- .fx-nav__report { margin-top: auto; border: 0; background: none; font: inherit; font-weight: 500; font-size: 14px; text-align: left; cursor: pointer; }
152
+ .fx-nav a:hover, .fx-nav__report:hover, .fx-nav__more:hover { color: var(--t-strong); background: var(--fx-hover); }
153
+ .fx-nav__report, .fx-nav__more { border: 0; background: none; font: inherit; font-weight: 500; font-size: 14px; text-align: left; cursor: pointer; }
154
+ .fx-nav__report { margin-top: auto; }
153
155
  .fx-nav__report[hidden] { display: none; }
154
- .fx-nav a[aria-current="page"] { color: var(--t-accent); background: var(--t-accent-bg); font-weight: 650; box-shadow: inset 2px 0 0 var(--t-brand); }
156
+ .fx-nav :is(a, .fx-nav__more)[aria-current="page"] { color: var(--t-accent); background: var(--t-accent-bg); font-weight: 650; box-shadow: inset 2px 0 0 var(--t-brand); }
155
157
  .fx-nav__count { margin-left: auto; color: var(--t-dim); font-size: 12px; font-weight: 700; }
156
158
  .fx-ico { display: inline-flex; flex: none; }
157
159
  .fx-ico svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
@@ -166,8 +168,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
166
168
  .fx-shell[data-folded] .fx-side :is(a, button, #user-button) { cursor: pointer; }
167
169
  .fx-shell[data-folded] .fx-side__head { flex-direction: column; gap: 10px; padding: 0 0 14px; }
168
170
  .fx-shell[data-folded] .fx-side__head .fx-brand { display: none; }
169
- .fx-shell[data-folded] .fx-nav__group { height: 1px; margin: 10px 6px; padding: 0; overflow: hidden; background: var(--fx-line); color: transparent; }
170
- .fx-shell[data-folded] :is(.fx-nav a, .fx-nav__report) { justify-content: center; padding: 9px 0; }
171
+ .fx-shell[data-folded] :is(.fx-nav a, .fx-nav__report, .fx-nav__more) { justify-content: center; padding: 9px 0; }
171
172
  .fx-shell[data-folded] .fx-nav__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
172
173
  .fx-shell[data-folded] .fx-side__end { flex-direction: column; padding: 12px 0 0; }
173
174
  .fx-shell[data-folded] #user-button { flex: none; }
@@ -422,7 +423,7 @@ a.fx-strong:hover { text-decoration: underline; }
422
423
  .fx-toolbar { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--fx-line); }
423
424
  .fx-tabs { display: flex; gap: 2px; }
424
425
  .fx-tabs button { flex: none; }
425
- @media (max-width: 640px) { .fx-tabs { max-width: 100%; overflow-x: auto; scrollbar-width: none; } }
426
+ @media (max-width: 639px) { .fx-tabs { max-width: 100%; overflow-x: auto; scrollbar-width: none; } }
426
427
  .fx-tabs button {
427
428
  display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; margin-bottom: -1px;
428
429
  border: 0; border-bottom: 2px solid transparent; background: transparent; cursor: pointer;
@@ -433,14 +434,17 @@ a.fx-strong:hover { text-decoration: underline; }
433
434
  .fx-tabs__count { padding: 0 7px; border-radius: 999px; background: var(--fx-hover); color: var(--t-dim); font-size: 12px; line-height: 20px; }
434
435
  .fx-tabs button[data-tone="danger"] .fx-tabs__count { background: var(--t-danger-bg); color: var(--t-danger); }
435
436
 
436
- /* Saved views (saved-views.js): the row of named view tabs over a list, as
437
- ClickUp's. The chosen tab is underlined; its ⋯ shows on hover and when
438
- chosen. Save and Revert sit at the right while the list differs. */
439
- .fx-views { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; min-height: 32px; margin-bottom: -8px; }
437
+ /* Saved views (saved-views.js): named view tabs in the toolbar, at the
438
+ right of Group, Sort and Filter (listHead's tools). The chosen tab is a
439
+ filled pill; its ⋯ shows on hover and when chosen. Save and Revert follow
440
+ while the list differs. */
441
+ .fx-toolbar__tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; }
442
+ .fx-views { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }
440
443
  .fx-views:empty { display: none; }
441
- .fx-views__item { display: inline-flex; align-items: center; border-bottom: 2px solid transparent; }
442
- .fx-views__item[data-on="true"] { border-bottom-color: var(--t-brand); }
443
- .fx-views__tab { display: inline-flex; align-items: center; gap: 6px; max-width: 220px; padding: 6px 4px 6px 8px; border: 0; background: none; color: var(--t-dim); font: 600 13px var(--t-body); cursor: pointer; }
444
+ .fx-views__item { display: inline-flex; align-items: center; border-radius: 999px; }
445
+ .fx-views__item:hover { background: var(--fx-hover); }
446
+ .fx-views__item[data-on="true"] { background: var(--t-accent-bg); }
447
+ .fx-views__tab { display: inline-flex; align-items: center; gap: 6px; max-width: 180px; padding: 6px 4px 6px 10px; border-radius: 999px; border: 0; background: none; color: var(--t-dim); font: 600 13px var(--t-body); cursor: pointer; }
444
448
  .fx-views__tab:hover, .fx-views__item[data-on="true"] .fx-views__tab { color: var(--t-strong); }
445
449
  .fx-views__tab:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 1px; }
446
450
  .fx-views__tab .fx-lucide { width: 14px; height: 14px; flex: none; }
@@ -449,11 +453,18 @@ a.fx-strong:hover { text-decoration: underline; }
449
453
  .fx-views__item .fx-cfm__more { width: 22px; height: 22px; opacity: 0; }
450
454
  .fx-views__item:hover .fx-cfm__more, .fx-views__item[data-on="true"] .fx-cfm__more, .fx-views__item .fx-cfm__more[aria-expanded="true"], .fx-views__item .fx-cfm__more:focus-visible { opacity: 1; }
451
455
  .fx-views__item .fx-cfm__more .fx-lucide { width: 14px; height: 14px; }
452
- .fx-views__add { display: inline-flex; align-items: center; gap: 4px; padding: 6px 8px; border: 0; background: none; color: var(--t-dim); font: 500 13px var(--t-body); cursor: pointer; }
453
- .fx-views__add:hover { color: var(--t-strong); }
456
+ .fx-views__add { display: inline-flex; align-items: center; gap: 4px; min-height: 30px; padding: 4px 9px; border: 0; border-radius: 999px; background: none; color: var(--t-dim); font: 600 13px/1.2 var(--t-body); cursor: pointer; }
457
+ .fx-views__add:hover { background: var(--fx-hover); color: var(--t-strong); }
458
+ .fx-views__add:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 2px; }
454
459
  .fx-views__add .fx-lucide { width: 14px; height: 14px; }
455
- .fx-views__end { display: inline-flex; gap: 6px; margin-left: auto; }
460
+ .fx-views__end { display: inline-flex; gap: 6px; margin-left: 4px; }
456
461
  @media (hover: none) { .fx-views__item .fx-cfm__more { opacity: 1; } }
462
+ /* A phone keeps the tools on one row of their own that scrolls sideways. */
463
+ @media (max-width: 639px) {
464
+ .fx-toolbar__tools { flex: 1 1 100%; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
465
+ .fx-toolbar__tools > *, .fx-views { flex: none; flex-wrap: nowrap; }
466
+ .fx-toolbar__end:has(> .fx-toolbar__tools) { flex-wrap: wrap; row-gap: 8px; }
467
+ }
457
468
 
458
469
  /* Quiet alerts (server/ui/alerts.js): one slim line under the page header,
459
470
  only while something on the page needs this person. */
@@ -468,7 +479,7 @@ a.fx-strong:hover { text-decoration: underline; }
468
479
  .fx-alerts__close:hover { background: var(--fx-hover); color: var(--t-strong); }
469
480
  .fx-alerts__close svg { width: 14px; height: 14px; }
470
481
  .fx-alerts__action:focus-visible, .fx-alerts__close:focus-visible { outline: 2px solid var(--t-ring); outline-offset: 2px; }
471
- @media (max-width: 560px) {
482
+ @media (max-width: 639px) {
472
483
  .fx-alerts { flex-wrap: wrap; }
473
484
  .fx-alerts__text { flex-basis: calc(100% - 52px); }
474
485
  .fx-alerts__action, .fx-alerts__more { order: 2; }
@@ -651,7 +662,7 @@ a.fx-strong:hover { text-decoration: underline; }
651
662
  .fx-dot--stage[data-rank="7"] { --stage: var(--t-dim); }
652
663
  .fx-pop--choices .fx-input { min-height: 32px; margin-bottom: 4px; font-size: 13px; }
653
664
  .fx-pop__end { display: inline-grid; margin-left: auto; color: var(--t-accent); }
654
- @media (max-width: 640px) {
665
+ @media (max-width: 639px) {
655
666
  .fx-pop.fx-pop--rows { min-width: 0; width: calc(100vw - 16px); }
656
667
  .fx-rows__row, .fx-rows__row--sort { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px; }
657
668
  .fx-rows__row > .fx-rows__lead, .fx-rows__row > .fx-select:first-child, .fx-rows__row > .fx-rows__join { grid-column: 1 / -1; }
@@ -730,18 +741,21 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
730
741
  .fx-table--sized[data-resizing] { cursor: col-resize; user-select: none; }
731
742
  .fx-table--sized .fx-tick-cell, .fx-table--sized .fx-th-add, .fx-table--sized .fx-c-end { overflow: visible; text-overflow: clip; }
732
743
  .fx-table--sized .fx-tick-cell { padding-right: 0; }
733
- /* + stays at the right edge while the table scrolls sideways. */
734
- .fx-table--sized .fx-th-add { position: sticky; right: 0; z-index: 1; padding-left: 0; padding-right: 12px; }
744
+ /* The end column fills the row after the last column (columns.js), as
745
+ HubSpot's; + sits at its right edge and stays there while the table
746
+ scrolls sideways. */
747
+ .fx-table--sized .fx-th-add { position: sticky; right: 0; z-index: 1; padding-left: 0; padding-right: 12px; text-align: right; }
735
748
  .fx-avatar--sm { width: 24px; height: 24px; font-size: 10px; }
736
749
  .fx-c-date time { font-variant-numeric: tabular-nums; }
737
750
  .fx-stage[data-rank="plain"] { --stage: var(--t-dim); }
738
- /* The board is one row of lanes (one per group), each about a phone wide.
739
- Lanes share the width when they fit; more lanes scroll sideways inside
740
- the board, never as a second row. On a wide, tall screen the board fills
751
+ /* The board is one row of lanes (one per group), each a fixed phone width
752
+ (--fx-lane), as HubSpot's: lanes never stretch to fill a wide screen, so
753
+ the space past the last lane stays empty. More lanes scroll sideways
754
+ inside the board, never as a second row. On a wide, tall screen the board fills
741
755
  the page down to its bottom edge, the page itself does not scroll, and
742
756
  each lane scrolls inside itself (jobs.js sets fx-main--board while the
743
757
  board shows). */
744
- .fx-board { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(min(85vw, 272px), 1fr); gap: 12px; align-items: start; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 4px; }
758
+ .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; }
745
759
  .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); }
746
760
  .fx-board__head { display: flex; flex: none; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 4px 2px; }
747
761
  .fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
@@ -1022,13 +1036,27 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
1022
1036
  .fx-auth__title { font-size: 26px; max-width: none; }
1023
1037
  }
1024
1038
 
1025
- /* A one-message page (sign-in off, an error, no access, Xero's return). */
1039
+ /* A one-message page (sign-in off, an error, no access, an OAuth return). */
1026
1040
  .fx-message, .fx-centre .fx-message { min-height: 100vh; place-content: center; justify-items: center; gap: 20px; padding: 32px 16px; }
1027
1041
  .fx-centre .fx-message { display: grid; }
1028
1042
  .fx-message__card { display: grid; gap: 12px; width: min(460px, 100%); padding: 28px; }
1029
1043
  .fx-message__card[data-tone="success"] { border-top: 3px solid var(--t-success); }
1030
1044
  .fx-message__card[data-tone="danger"] { border-top: 3px solid var(--t-danger); }
1031
1045
  .fx-message__card .fx-btn { justify-self: start; }
1046
+ .fx-message__marks {
1047
+ display: flex; align-items: center; gap: 10px; margin-bottom: 4px;
1048
+ }
1049
+ .fx-message__marks--solo { justify-content: flex-start; }
1050
+ .fx-message__flow .fx-mark { width: 34px; height: 34px; display: block; }
1051
+ .fx-message__plus { color: var(--t-dim); font: 600 18px/1 var(--t-body); }
1052
+ .fx-message__result {
1053
+ display: grid; place-items: center; width: 28px; height: 28px; margin-left: 4px;
1054
+ border-radius: 50%; color: var(--t-success); background: var(--t-success-bg);
1055
+ }
1056
+ .fx-message__result[data-tone="danger"] { color: var(--t-danger); background: var(--t-danger-bg); }
1057
+ .fx-message__result .fx-lucide { width: 16px; height: 16px; }
1058
+ .fx-message__status { margin: 0; }
1059
+ .fx-message__return { margin: 0; color: var(--t-dim); font-size: 13.5px; }
1032
1060
 
1033
1061
  .fx-loading { display: none; min-height: 100vh; place-content: center; gap: 12px; color: var(--t-dim); }
1034
1062
  body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
@@ -1057,7 +1085,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1057
1085
  .fx-setting__value { font-size: 12.5px; color: var(--t-strong); overflow-wrap: anywhere; text-align: right; }
1058
1086
  .fx-setting--wide { grid-template-columns: minmax(0, 1fr); }
1059
1087
  .fx-setting--wide .fx-setting__control { display: block; }
1060
- @media (max-width: 640px) {
1088
+ @media (max-width: 639px) {
1061
1089
  .fx-setting { grid-template-columns: minmax(0, 1fr); }
1062
1090
  .fx-setting__control { justify-content: flex-start; }
1063
1091
  .fx-setting__stack { justify-items: start; }
@@ -1144,13 +1172,14 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1144
1172
  }
1145
1173
 
1146
1174
  /* ── Phones ─────────────────────────────────────────────────────────────── */
1147
- @media (max-width: 640px) {
1175
+ @media (max-width: 639px) {
1148
1176
  .fx-main { padding-top: 20px; }
1149
1177
  .fx-title { font-size: 22px; }
1150
1178
  .fx-table thead { display: none; }
1151
1179
  .fx-table.fx-table--sized { width: 100% !important; min-width: 0; table-layout: auto; }
1152
1180
  .fx-table--sized td { white-space: normal; }
1153
1181
  .fx-table--sized colgroup { display: none; }
1182
+ .fx-table--sized td.fx-c-end { display: none; }
1154
1183
  .fx-table, .fx-table tbody, .fx-table tr, .fx-table td { display: block; width: 100%; }
1155
1184
  .fx-table tr { padding: 12px 14px; border-bottom: 1px solid var(--fx-line-soft); }
1156
1185
  .fx-table td { padding: 4px 0; border: 0; }
@@ -1210,8 +1239,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1210
1239
  .fx-price { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
1211
1240
  .fx-price__head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 8px 16px; }
1212
1241
  .fx-price__head .fx-search { margin: 0 0 6px; width: 260px; }
1213
- @media (max-width: 560px) { .fx-price__head .fx-search { width: 100%; } }
1214
- .fx-price__scroll { overflow-x: auto; border: 1px solid var(--fx-line); background: var(--t-paper); }
1242
+ @media (max-width: 639px) { .fx-price__head .fx-search { width: 100%; } }
1215
1243
  .fx-price__table th { position: sticky; top: 0; z-index: 1; }
1216
1244
  .fx-price__table td { padding-top: 9px; padding-bottom: 9px; }
1217
1245
  .fx-price__table td.fx-num, .fx-price__table th.fx-num { text-align: right; white-space: nowrap; }
@@ -1404,10 +1432,10 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1404
1432
  .fx-rule__value { display: flex; gap: 6px; align-items: center; min-width: 0; }
1405
1433
  .fx-rule__many { display: grid; gap: 2px; max-height: 160px; overflow: auto; padding: 4px 8px; border: 1px solid var(--fx-line); border-radius: 8px; width: 100%; }
1406
1434
  .fx-rule__delete { justify-self: start; color: var(--t-danger); }
1407
- @media (max-width: 640px) {
1435
+ @media (max-width: 639px) {
1408
1436
  .fx-rule__condition { grid-template-columns: minmax(0, 1fr); }
1409
1437
  }
1410
- @media (max-width: 560px) {
1438
+ @media (max-width: 639px) {
1411
1439
  .fx-set__row { gap: 12px; }
1412
1440
  .fx-set__control .fx-input { width: 140px; }
1413
1441
  .fx-set__control .fx-input[type="number"] { width: 72px; }
@@ -1415,7 +1443,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1415
1443
  }
1416
1444
  .fx-multi { position: relative; width: 220px; max-width: 100%; }
1417
1445
  .fx-multi__summary { display: flex; align-items: center; list-style: none; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1418
- @media (max-width: 600px) { .fx-multi { width: 150px; } }
1446
+ @media (max-width: 639px) { .fx-multi { width: 150px; } }
1419
1447
  .fx-multi__summary::-webkit-details-marker { display: none; }
1420
1448
  .fx-multi__list { position: absolute; right: 0; top: calc(100% + 4px); z-index: 30; display: grid; gap: 2px; min-width: 100%; max-height: 280px; overflow-y: auto; padding: 6px; background: var(--t-paper); border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); box-shadow: var(--t-shadow); }
1421
1449
  .fx-multi__option { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 6px 8px; cursor: pointer; white-space: nowrap; }
@@ -1464,6 +1492,19 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1464
1492
  .fx-pager__step:hover:not(:disabled) { background: var(--fx-hover); border-color: var(--fx-line); }
1465
1493
  .fx-pager__step:disabled { opacity: 0.35; cursor: default; }
1466
1494
 
1495
+ /* The pages of a long list, in the list window's foot bar (pager.js):
1496
+ rows per page, the range, numbered pages. The page shown is filled. */
1497
+ .fx-pages { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; color: var(--t-dim); font-size: 13px; font-variant-numeric: tabular-nums; }
1498
+ .fx-pages__rows { display: inline-flex; align-items: center; gap: 6px; }
1499
+ .fx-pages__size { width: 76px; min-height: 28px; padding-block: 2px; font-size: 13px; }
1500
+ .fx-pages__range { color: var(--t-text); }
1501
+ .fx-pages__steps { display: inline-flex; align-items: center; gap: 4px; }
1502
+ .fx-pages__step, .fx-pages__num { display: inline-grid; place-items: center; min-width: 28px; height: 28px; padding: 0 6px; border: 1px solid var(--fx-line-soft); border-radius: 999px; background: transparent; color: var(--t-text); font: inherit; cursor: pointer; transition: background 120ms var(--fx-ease), border-color 120ms var(--fx-ease); }
1503
+ .fx-pages__step:hover:not(:disabled), .fx-pages__num:hover:not([aria-current]) { background: var(--fx-hover); border-color: var(--fx-line); }
1504
+ .fx-pages__step:disabled { opacity: 0.35; cursor: default; }
1505
+ .fx-pages__num[aria-current="page"] { background: var(--t-primary); border-color: var(--t-primary); color: var(--t-on-primary); cursor: default; }
1506
+ .fx-pages__gap { min-width: 16px; text-align: center; color: var(--t-mute); }
1507
+
1467
1508
  /* New invoice from a job: a searchable list of jobs with no invoice. */
1468
1509
  .fx-dialog:has(.fx-pick__body) { width: min(620px, calc(100vw - 32px)); }
1469
1510
  .fx-pick__body { display: grid; gap: 12px; }
@@ -1490,9 +1531,19 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1490
1531
  .fx-listhead__edge { height: 0; }
1491
1532
  .fx-listhead__edge + .fx-listhead[data-sticky] { margin-top: -32px; }
1492
1533
  .fx-listhead > .fx-toolbar > .fx-tabs button { padding-block: 9px; }
1534
+ /* Every control in the toolbar is one height (--fx-control-h). Under a
1535
+ tab line, the controls keep 8px clear of the line; only the tabs sit on it. */
1536
+ .fx-listhead .fx-toolbar :is(.fx-btn, .fx-chip, .fx-views__add, .fx-views__tab) { min-height: var(--fx-control-h); }
1537
+ .fx-listhead .fx-toolbar .fx-btn:not(.fx-btn--sm) { padding-block: 0; font-size: 14px; }
1538
+ .fx-listhead .fx-toolbar :is(.fx-btn, .fx-chip) { height: var(--fx-control-h); }
1539
+ .fx-listhead .fx-toolbar .fx-find__button { width: var(--fx-control-h); height: var(--fx-control-h); }
1540
+ .fx-listhead .fx-toolbar .fx-find__input { height: var(--fx-control-h); }
1541
+ .fx-listhead .fx-toolbar .fx-seg { padding: 2px; }
1542
+ .fx-listhead .fx-toolbar .fx-seg__item { min-height: calc(var(--fx-control-h) - 4px); }
1543
+ .fx-listhead > .fx-toolbar:not(.fx-toolbar--plain) > :not(.fx-tabs) { margin-bottom: 8px; }
1493
1544
  @media (max-width: 820px) { .fx-listhead[data-sticky] { top: 60px; } }
1494
1545
  /* On a phone the head would hide too many rows: it scrolls away. */
1495
- @media (max-width: 560px) { .fx-listhead[data-sticky] { position: static; } }
1546
+ @media (max-width: 639px) { .fx-listhead[data-sticky] { position: static; } }
1496
1547
 
1497
1548
  /* The bulk action bar (bulk-bar.js): floats at the foot while rows are
1498
1549
  ticked. "N selected ×", then icon actions in groups, then More. It takes
@@ -1526,7 +1577,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1526
1577
  .fx-bulk__grid { display: grid; }
1527
1578
  .fx-bulk__grid .fx-pop__item[data-tone="danger"] { color: var(--t-danger); }
1528
1579
  .fx-bulk__none { margin: 8px 10px; color: var(--t-dim); font-size: 13.5px; }
1529
- @media (max-width: 560px) {
1580
+ @media (max-width: 639px) {
1530
1581
  .fx-bulk { bottom: 10px; padding: 4px; }
1531
1582
  .fx-bulk__count { margin-right: 2px; padding-left: 8px; }
1532
1583
  .fx-bulk__act { padding: 7px 6px; }
@@ -1588,3 +1639,63 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1588
1639
  .fx-find__input { display: none; width: min(240px, 60vw); height: 32px; padding: 0 10px; }
1589
1640
  .fx-find--open .fx-find__input { display: block; }
1590
1641
  .fx-find--open .fx-find__button { display: none; }
1642
+
1643
+ /* Xero usage on the Xero card (server/ui/xero-usage.js). Colours are tokens. */
1644
+ .fx-xero-orgs { display: grid; gap: 18px; }
1645
+ .fx-xero-org { display: grid; gap: 12px; }
1646
+ .fx-xu { display: grid; gap: 14px; }
1647
+ .fx-xu__top { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
1648
+ .fx-xu__chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
1649
+ .fx-xu-ring { position: relative; width: 112px; height: 112px; flex: none; }
1650
+ .fx-xu-ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
1651
+ .fx-xu-ring__track { fill: none; stroke: var(--fx-line); stroke-width: 3; }
1652
+ .fx-xu-ring__used { fill: none; stroke: var(--t-accent); stroke-width: 3; stroke-linecap: round; }
1653
+ .fx-xu[data-tone="warn"] .fx-xu-ring__used { stroke: var(--t-warn); }
1654
+ .fx-xu[data-tone="danger"] .fx-xu-ring__used { stroke: var(--t-danger); }
1655
+ .fx-xu-ring__label { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 2px; }
1656
+ .fx-xu-ring__num { color: var(--t-strong); font: 650 22px/1 var(--t-body); font-variant-numeric: tabular-nums; }
1657
+ .fx-xu-ring__of { color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
1658
+ .fx-xu__fold { display: grid; gap: 12px; padding: 12px 14px; border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); background: var(--t-paper); }
1659
+ .fx-xu__fold summary { cursor: pointer; color: var(--t-strong); font-weight: 650; }
1660
+ .fx-xu__fold summary:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 2px; }
1661
+ .fx-xu-legend { display: flex; flex-wrap: wrap; gap: 8px 14px; }
1662
+ .fx-xu-legend span { display: inline-flex; align-items: center; gap: 6px; color: var(--t-dim); font-size: 12.5px; }
1663
+ .fx-xu-legend i, .fx-xu-lane { display: inline-block; }
1664
+ .fx-xu-legend i { width: 8px; height: 8px; border-radius: 2px; }
1665
+ .fx-xu-hours { display: flex; align-items: flex-end; gap: 3px; height: 88px; }
1666
+ .fx-xu-hour { flex: 1; min-width: 0; height: 100%; display: flex; align-items: flex-end; }
1667
+ .fx-xu-hour__stack { display: flex; flex-direction: column-reverse; width: 100%; height: var(--h, 0%); border-radius: 2px 2px 0 0; overflow: hidden; }
1668
+ .fx-xu-hour__stack i { display: block; width: 100%; height: var(--part, 0%); }
1669
+ .fx-xu-lane[data-lane="writes"] { background: var(--t-accent); }
1670
+ .fx-xu-lane[data-lane="sync"] { background: var(--t-success); }
1671
+ .fx-xu-lane[data-lane="reads"] { background: var(--t-warn); }
1672
+ .fx-xu-lane[data-lane="other"] { background: color-mix(in srgb, var(--t-text) 35%, var(--t-bg)); }
1673
+ .fx-xu-budget { display: flex; height: 10px; border-radius: 999px; overflow: hidden; background: var(--fx-hover); }
1674
+ .fx-xu-budget i { flex: none; width: var(--part, 0%); height: 100%; }
1675
+ .fx-xu-dots { display: flex; flex-wrap: wrap; gap: 12px 16px; }
1676
+ .fx-xu-dot { display: inline-flex; align-items: center; gap: 6px; color: var(--t-dim); font-size: 12.5px; }
1677
+ .fx-xu-dot::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--t-dim); }
1678
+ .fx-xu-dot[data-tone="success"]::before { background: var(--t-success); }
1679
+ .fx-xu-dot[data-tone="warn"]::before { background: var(--t-warn); }
1680
+ .fx-xu-dot[data-tone="danger"]::before { background: var(--t-danger); }
1681
+ .fx-xu-days { position: relative; display: flex; align-items: stretch; gap: 8px; height: 108px; }
1682
+ .fx-xu-days__line { position: absolute; left: 0; right: 0; bottom: calc(16px + (100% - 20px) * var(--warn, 0.7)); height: 0; border-top: 1px dashed var(--t-warn); pointer-events: none; }
1683
+ .fx-xu-day { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 4px; }
1684
+ .fx-xu-day__bar { flex: 1; display: flex; align-items: flex-end; justify-content: center; width: 100%; }
1685
+ .fx-xu-day b { display: block; width: 70%; max-width: 28px; height: var(--h, 0%); border-radius: 3px 3px 0 0; background: var(--t-accent); }
1686
+ .fx-xu-day[data-warning="true"] b { background: var(--t-warn); }
1687
+ .fx-xu-day[data-over="true"] b { background: var(--t-danger); }
1688
+ .fx-xu-day > span:last-child { color: var(--t-dim); font-size: 11px; font-variant-numeric: tabular-nums; }
1689
+ .fx-xu-rows { display: grid; gap: 6px; }
1690
+ .fx-xu-row { display: flex; justify-content: space-between; gap: 12px; font-size: 13px; }
1691
+ .fx-xu-row span { color: var(--t-text); }
1692
+ .fx-xu-row b { color: var(--t-strong); font-weight: 650; font-variant-numeric: tabular-nums; }
1693
+ .fx-xu .fx-notice { margin-top: 4px; }
1694
+ @media (max-width: 639px) {
1695
+ .fx-xu__top { gap: 14px; }
1696
+ .fx-xu-ring { width: 96px; height: 96px; }
1697
+ .fx-xu-ring__num { font-size: 20px; }
1698
+ .fx-xu-hours { overflow-x: auto; }
1699
+ .fx-xu-hour { flex: none; width: 10px; }
1700
+ .fx-xu__fold { padding: 12px; }
1701
+ }
@@ -10,9 +10,10 @@ 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 { jobChangeReview } from '/assets/ui/job-change.js';
13
- import { columnTable, rowCells, viewSaver } from '/assets/ui/columns.js';
14
- import { filterJobs, INVOICE_COLUMNS, normaliseListView } from '/assets/ui/job-view.js';
15
- import { INVOICE_FILTER_FIELDS, normaliseInvoicesView } from '/assets/ui/list-views.js';
13
+ import { columnTable, dateCell, fieldColumn, rowCells, viewSaver } from '/assets/ui/columns.js';
14
+ import { fieldGroup, filterCount, filterJobs, INVOICE_COLUMNS, invoiceColumnsView } from '/assets/ui/job-view.js';
15
+ import { INVOICE_FILTER_FIELDS, invoiceFilterKind, invoiceValue, normaliseInvoicesView } from '/assets/ui/list-views.js';
16
+ import { listPager, PAGE_SIZES } from '/assets/ui/pager.js';
16
17
  import { savedViews } from '/assets/ui/saved-views.js';
17
18
  import { findBox, today, viewBar } from '/assets/ui/view-bar.js';
18
19
 
@@ -114,28 +115,25 @@ const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString(
114
115
 
115
116
  // ── The list ───────────────────────────────────────────────────────────────
116
117
 
117
- // The filter's fields: list-views.js (the server checks saved views with them).
118
+ // The filter's fields and rules: list-views.js. The server checks saved
119
+ // views with them and filters every invoice of a tab (GET /invoices).
118
120
  const FILTER_FIELDS = INVOICE_FILTER_FIELDS;
119
121
  const PROBLEM_OPS = { yes: 'Has one', no: 'Has none' };
120
122
  const FILTER_KEY = 'flow_invoice_filters';
121
- const filterKind = (field) => FILTER_FIELDS[field]?.kind ?? null;
122
-
123
- // An invoice's value for a filter field, as the filter compares it.
124
- export function invoiceValue(invoice, field) {
125
- switch (field) {
126
- case 'client': return invoice.contactName ?? null;
127
- case 'number': return invoice.number ?? null;
128
- case 'reference': return invoice.reference ?? null;
129
- case 'date': return invoice.issueDate ?? invoice.createdAt?.slice(0, 10) ?? null;
130
- case 'total': return invoice.totalCents == null ? null : invoice.totalCents / 100;
131
- case 'problem': return (invoice.problems?.length ?? 0) > 0;
132
- default: return null;
133
- }
134
- }
123
+ const SIZE_KEY = 'flow_invoice_page_size';
124
+ const filterKind = invoiceFilterKind;
125
+ export { invoiceValue };
135
126
 
136
127
  // The invoices a filter view keeps.
137
128
  export const keptInvoices = (invoices, view, now = today()) => filterJobs(invoices, view, { valueOf: invoiceValue, kindOf: filterKind, today: now });
138
129
 
130
+ // Rows per page, kept on this device.
131
+ function readSize() {
132
+ let size = null;
133
+ try { size = Number(localStorage.getItem(SIZE_KEY)); } catch { /* none kept */ }
134
+ return PAGE_SIZES.includes(size) ? size : 100;
135
+ }
136
+
139
137
  function readFilters() {
140
138
  try { return normaliseInvoicesView(JSON.parse(localStorage.getItem(FILTER_KEY))); } catch { return normaliseInvoicesView(null); }
141
139
  }
@@ -149,21 +147,27 @@ export async function renderInvoiceList(main, ui) {
149
147
  sessionStorage.removeItem(NAV_BACK);
150
148
  const state = {
151
149
  status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set(), view: readFilters(),
152
- columns: normaliseListView(ui.me?.views?.invoices, INVOICE_COLUMNS),
150
+ // The page of rows shown: the server filters and cuts it (GET /invoices).
151
+ offset: returning ? back?.offset ?? 0 : 0, limit: readSize(),
152
+ columns: invoiceColumnsView(ui.me?.views?.invoices),
153
+ // The lab's custom fields, their job values and the people a person
154
+ // field names (GET /api/custom-fields): columns a person can add.
155
+ fields: [], values: new Map(), people: [],
153
156
  };
154
157
  // The list's columns: resize, move, hide, kept with the account. A tab
155
- // has some of them (`available`, set as it draws).
158
+ // has some of them (`available`, set as it draws), each with its label.
156
159
  let available = Object.keys(INVOICE_COLUMNS);
160
+ const labelOf = (id) => INVOICE_COLUMNS[id] ?? state.fields.find((f) => fieldGroup(f.id) === id)?.name ?? id;
157
161
  const saveColumns = viewSaver(ui, 'invoices');
158
162
  const heads = columnTable(h, {
159
163
  order: () => state.columns.columns,
160
164
  widths: () => state.columns.widths,
161
165
  change: (patch) => {
162
- state.columns = normaliseListView({ ...state.columns, ...patch }, INVOICE_COLUMNS);
166
+ state.columns = invoiceColumnsView({ ...state.columns, ...patch });
163
167
  saveColumns(state.columns);
164
168
  if (raw) draw(raw);
165
169
  },
166
- hidden: () => available.filter((id) => !state.columns.columns.includes(id)).map((id) => ({ id, label: INVOICE_COLUMNS[id] })),
170
+ hidden: () => available.filter((id) => !state.columns.columns.includes(id)).map((id) => ({ id, label: labelOf(id) })),
167
171
  });
168
172
  const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
169
173
  let timer;
@@ -171,17 +175,18 @@ export async function renderInvoiceList(main, ui) {
171
175
  label: 'Find a number, client or reference', value: state.q,
172
176
  oninput: (words) => {
173
177
  clearTimeout(timer);
174
- timer = setTimeout(() => { state.q = words.trim(); load(); }, 200);
178
+ timer = setTimeout(() => { state.q = words.trim(); state.offset = 0; load(); }, 200);
175
179
  },
176
180
  });
177
181
  // The Filter chip: the same panel as on Jobs and Reconcile (view-bar.js).
178
- // It filters the rows of the tab shown, on this device.
179
- // A change to the filter: kept in this browser, and drawn.
182
+ // The server filters every invoice of the tab shown.
183
+ // A change to the filter: kept in this browser, and the first page loads.
180
184
  function setView(patch) {
181
185
  state.view = normaliseInvoicesView({ ...state.view, ...patch });
182
186
  try { localStorage.setItem(FILTER_KEY, JSON.stringify(state.view)); } catch { /* the page still works, it forgets */ }
183
187
  views.draw();
184
- if (raw) draw(raw);
188
+ state.offset = 0;
189
+ if (raw) load();
185
190
  }
186
191
  // The person's named views (saved-views.js): their default opens on a
187
192
  // new visit.
@@ -189,12 +194,11 @@ export async function renderInvoiceList(main, ui) {
189
194
  const opening = views.opening();
190
195
  if (opening) state.view = normaliseInvoicesView(opening);
191
196
  views.draw();
192
- const labels = (list) => [...new Set(list.filter(Boolean))].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
193
197
  const filterMenu = viewBar(h).filterMenu({
194
198
  fields: () => Object.entries(FILTER_FIELDS).map(([value, f]) => ({ value, label: f.label, icon: f.icon })),
195
199
  kindOf: filterKind,
196
200
  opsOf: (field) => (field === 'problem' ? PROBLEM_OPS : null),
197
- choicesOf: (field) => (field === 'client' ? labels((raw?.invoices ?? []).map((i) => i.contactName)) : []),
201
+ choicesOf: (field) => (field === 'client' ? (raw?.clients ?? []).map((v) => ({ value: v, label: v })) : []),
198
202
  view: () => state.view,
199
203
  change: setView,
200
204
  first: 'client',
@@ -203,20 +207,42 @@ export async function renderInvoiceList(main, ui) {
203
207
  const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Filter' }, ...filterMenu.node);
204
208
  const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
205
209
  const bulk = bulkBar(ui);
206
- const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
210
+ // The pages, in the foot under the rows (pager.js).
211
+ const pager = listPager(h, {
212
+ label: 'Invoice pages',
213
+ change: ({ offset, limit }) => {
214
+ if (limit !== state.limit) { try { localStorage.setItem(SIZE_KEY, String(limit)); } catch { /* it forgets */ } }
215
+ Object.assign(state, { offset, limit });
216
+ state.ticked.clear();
217
+ // A new page opens at its first row.
218
+ frame.body.scrollTop = 0;
219
+ load();
220
+ },
221
+ });
222
+ // Custom fields are extra: the list still shows without them.
223
+ const [context, fields, values] = await Promise.all([
224
+ flow('/api/invoicing/context').catch(() => ({ canIssue: false })),
225
+ flow('/api/custom-fields').catch(() => null), flow('/api/custom-fields/values').catch(() => null),
226
+ ]);
227
+ state.fields = fields?.fields ?? [];
228
+ state.people = fields?.people ?? [];
229
+ const valueKey = (job, fieldId) => `${job.sourceSystem}\u0000${job.sourceJobRef}\u0000${fieldId}`;
230
+ state.values = new Map((values?.values ?? []).map((v) => [valueKey(v, v.fieldId), v.value]));
207
231
  let body = null;
208
232
  let raw = null;
209
- const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
233
+ const openTab = (id) => { state.status = id; state.offset = 0; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
210
234
  const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
211
235
  // The tabs and search stay at the top while the rows scroll up under them;
212
236
  // the bulk bar floats at the foot (ClickUp's layout). Sending is a bulk
213
237
  // action: tick rows (the head tick takes every row shown), then Send.
214
238
  const head = listHead('Invoices', {
215
239
  actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
216
- views: views.element, start: tabs, end: [controls, find.element], line: true, sticky: true,
240
+ views: views.element, start: tabs, controls, controlsAt: 'end', end: find.element, line: true, sticky: true,
217
241
  });
218
242
  const [title, ...top] = head.nodes;
219
243
  const frame = listFrame(main, host);
244
+ // The pages sit in the window's foot bar, beside the count.
245
+ frame.tools.append(pager.element);
220
246
  main.append(title, alerts.element, ...top, frame.element, bulk.element);
221
247
 
222
248
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
@@ -373,7 +399,11 @@ export async function renderInvoiceList(main, ui) {
373
399
  const seen = new Map();
374
400
  let asked = 0;
375
401
  async function load() {
376
- const query = new URLSearchParams({ status: state.status, ...(state.q ? { q: state.q } : {}) });
402
+ const query = new URLSearchParams({
403
+ status: state.status, offset: String(state.offset), limit: String(state.limit), today: today(),
404
+ ...(state.q ? { q: state.q } : {}),
405
+ ...(filterCount(state.view, filterKind) ? { filter: JSON.stringify(state.view) } : {}),
406
+ });
377
407
  const key = String(query);
378
408
  const mine = ++asked;
379
409
  if (seen.has(key)) {
@@ -414,14 +444,22 @@ export async function renderInvoiceList(main, ui) {
414
444
  async function draw(answer) {
415
445
  raw = answer;
416
446
  filterMenu.draw();
417
- body = { ...answer, invoices: keptInvoices(answer.invoices, state.view) };
447
+ body = answer;
448
+ // A page past the end (rows went to another tab): show the last one.
449
+ if (!answer.invoices.length && answer.total && answer.offset) {
450
+ state.offset = Math.max(0, Math.floor((answer.total - 1) / state.limit) * state.limit);
451
+ load();
452
+ return;
453
+ }
454
+ pager.draw({ offset: answer.offset ?? 0, limit: answer.limit ?? state.limit, total: answer.total ?? answer.invoices.length });
418
455
  const shown = new Set(body.invoices.map((i) => i.id));
419
- frame.count(plural(body.invoices.length, 'invoice', 'invoices'));
456
+ const total = answer.total ?? body.invoices.length;
457
+ frame.count(`${total.toLocaleString('en-AU')} ${total === 1 ? 'invoice' : 'invoices'}${total ? ` · ${formatCents(answer.totalCents ?? 0)}` : ''}`);
420
458
  for (const id of state.ticked) if (!shown.has(id)) state.ticked.delete(id);
421
459
  drawTabs(body.counts);
422
460
  drawBar();
423
461
  if (!body.invoices.length) {
424
- host.replaceChildren(h('p', { class: 'fx-empty' }, answer.invoices.length
462
+ host.replaceChildren(h('p', { class: 'fx-empty' }, filterCount(state.view, filterKind)
425
463
  ? 'No invoice in this tab passes the filter.'
426
464
  : state.q
427
465
  ? 'No invoice matches that search.'
@@ -429,7 +467,7 @@ export async function renderInvoiceList(main, ui) {
429
467
  return;
430
468
  }
431
469
  // The invoice page steps through this list, in this order (navBar).
432
- saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
470
+ saveNav({ tab: state.status, q: state.q, offset: state.offset, ids: body.invoices.map((i) => i.id) });
433
471
  const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY, frameY: frame.body.scrollTop }); go(href); };
434
472
  const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
435
473
  const showProblem = state.status === 'pending';
@@ -487,13 +525,24 @@ export async function renderInvoiceList(main, ui) {
487
525
  reference: showProblem
488
526
  ? { label: 'Problem', kind: 'text', cell: problemCell }
489
527
  : { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
490
- date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) => showDate(i.issueDate ?? i.createdAt?.slice(0, 10)) },
528
+ date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.issueDate ?? i.createdAt?.slice(0, 10)) },
529
+ due: { kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.dueDate) },
491
530
  total: { kind: 'total', num: true, className: 'fx-strong', cell: (i) => formatCents(i.totalCents) },
492
531
  status: { kind: 'status', className: 'fx-status-cell', cell: statusCell },
493
532
  linked: linked ? { kind: 'link', cell: linkCell } : null,
533
+ // The invoice's job (from the work system): its case, practitioner and
534
+ // the day it shipped.
535
+ case: { kind: 'case', className: 'fx-dim', cell: (i) => (i.job ? caseNumber(i.job) ?? '—' : '—') },
536
+ practitioner: { kind: 'text', className: 'fx-dim', cell: (i) => i.job?.practitioner ?? '—' },
537
+ shipped: { kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.job?.shippedOn ?? null) },
494
538
  };
539
+ // Each custom field is a column too: the value of the invoice's job.
540
+ for (const field of state.fields) {
541
+ const id = fieldGroup(field.id);
542
+ ALL[id] = fieldColumn(h, field, { id, valueOf: (i) => (i.job ? state.values.get(valueKey(i.job, field.id)) ?? null : null), people: state.people });
543
+ }
495
544
  available = Object.keys(ALL).filter((id) => ALL[id]);
496
- const columns = state.columns.columns.filter((id) => ALL[id]).map((id) => ({ id, label: INVOICE_COLUMNS[id], ...ALL[id] }));
545
+ const columns = state.columns.columns.filter((id) => ALL[id]).map((id) => ({ id, label: labelOf(id), ...ALL[id] }));
497
546
  host.replaceChildren(heads.table({
498
547
  className: 'fx-table--rows',
499
548
  columns,