@arcforgelabs/flow 2026.10.45 → 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);
@@ -420,7 +423,7 @@ a.fx-strong:hover { text-decoration: underline; }
420
423
  .fx-toolbar { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--fx-line); }
421
424
  .fx-tabs { display: flex; gap: 2px; }
422
425
  .fx-tabs button { flex: none; }
423
- @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; } }
424
427
  .fx-tabs button {
425
428
  display: inline-flex; align-items: center; gap: 8px; padding: 10px 14px; margin-bottom: -1px;
426
429
  border: 0; border-bottom: 2px solid transparent; background: transparent; cursor: pointer;
@@ -431,14 +434,17 @@ a.fx-strong:hover { text-decoration: underline; }
431
434
  .fx-tabs__count { padding: 0 7px; border-radius: 999px; background: var(--fx-hover); color: var(--t-dim); font-size: 12px; line-height: 20px; }
432
435
  .fx-tabs button[data-tone="danger"] .fx-tabs__count { background: var(--t-danger-bg); color: var(--t-danger); }
433
436
 
434
- /* Saved views (saved-views.js): the row of named view tabs over a list, as
435
- ClickUp's. The chosen tab is underlined; its ⋯ shows on hover and when
436
- chosen. Save and Revert sit at the right while the list differs. */
437
- .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; }
438
443
  .fx-views:empty { display: none; }
439
- .fx-views__item { display: inline-flex; align-items: center; border-bottom: 2px solid transparent; }
440
- .fx-views__item[data-on="true"] { border-bottom-color: var(--t-brand); }
441
- .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; }
442
448
  .fx-views__tab:hover, .fx-views__item[data-on="true"] .fx-views__tab { color: var(--t-strong); }
443
449
  .fx-views__tab:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 1px; }
444
450
  .fx-views__tab .fx-lucide { width: 14px; height: 14px; flex: none; }
@@ -447,11 +453,18 @@ a.fx-strong:hover { text-decoration: underline; }
447
453
  .fx-views__item .fx-cfm__more { width: 22px; height: 22px; opacity: 0; }
448
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; }
449
455
  .fx-views__item .fx-cfm__more .fx-lucide { width: 14px; height: 14px; }
450
- .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; }
451
- .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; }
452
459
  .fx-views__add .fx-lucide { width: 14px; height: 14px; }
453
- .fx-views__end { display: inline-flex; gap: 6px; margin-left: auto; }
460
+ .fx-views__end { display: inline-flex; gap: 6px; margin-left: 4px; }
454
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
+ }
455
468
 
456
469
  /* Quiet alerts (server/ui/alerts.js): one slim line under the page header,
457
470
  only while something on the page needs this person. */
@@ -466,7 +479,7 @@ a.fx-strong:hover { text-decoration: underline; }
466
479
  .fx-alerts__close:hover { background: var(--fx-hover); color: var(--t-strong); }
467
480
  .fx-alerts__close svg { width: 14px; height: 14px; }
468
481
  .fx-alerts__action:focus-visible, .fx-alerts__close:focus-visible { outline: 2px solid var(--t-ring); outline-offset: 2px; }
469
- @media (max-width: 560px) {
482
+ @media (max-width: 639px) {
470
483
  .fx-alerts { flex-wrap: wrap; }
471
484
  .fx-alerts__text { flex-basis: calc(100% - 52px); }
472
485
  .fx-alerts__action, .fx-alerts__more { order: 2; }
@@ -649,7 +662,7 @@ a.fx-strong:hover { text-decoration: underline; }
649
662
  .fx-dot--stage[data-rank="7"] { --stage: var(--t-dim); }
650
663
  .fx-pop--choices .fx-input { min-height: 32px; margin-bottom: 4px; font-size: 13px; }
651
664
  .fx-pop__end { display: inline-grid; margin-left: auto; color: var(--t-accent); }
652
- @media (max-width: 640px) {
665
+ @media (max-width: 639px) {
653
666
  .fx-pop.fx-pop--rows { min-width: 0; width: calc(100vw - 16px); }
654
667
  .fx-rows__row, .fx-rows__row--sort { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 32px; }
655
668
  .fx-rows__row > .fx-rows__lead, .fx-rows__row > .fx-select:first-child, .fx-rows__row > .fx-rows__join { grid-column: 1 / -1; }
@@ -1023,13 +1036,27 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
1023
1036
  .fx-auth__title { font-size: 26px; max-width: none; }
1024
1037
  }
1025
1038
 
1026
- /* 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). */
1027
1040
  .fx-message, .fx-centre .fx-message { min-height: 100vh; place-content: center; justify-items: center; gap: 20px; padding: 32px 16px; }
1028
1041
  .fx-centre .fx-message { display: grid; }
1029
1042
  .fx-message__card { display: grid; gap: 12px; width: min(460px, 100%); padding: 28px; }
1030
1043
  .fx-message__card[data-tone="success"] { border-top: 3px solid var(--t-success); }
1031
1044
  .fx-message__card[data-tone="danger"] { border-top: 3px solid var(--t-danger); }
1032
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; }
1033
1060
 
1034
1061
  .fx-loading { display: none; min-height: 100vh; place-content: center; gap: 12px; color: var(--t-dim); }
1035
1062
  body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
@@ -1058,7 +1085,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1058
1085
  .fx-setting__value { font-size: 12.5px; color: var(--t-strong); overflow-wrap: anywhere; text-align: right; }
1059
1086
  .fx-setting--wide { grid-template-columns: minmax(0, 1fr); }
1060
1087
  .fx-setting--wide .fx-setting__control { display: block; }
1061
- @media (max-width: 640px) {
1088
+ @media (max-width: 639px) {
1062
1089
  .fx-setting { grid-template-columns: minmax(0, 1fr); }
1063
1090
  .fx-setting__control { justify-content: flex-start; }
1064
1091
  .fx-setting__stack { justify-items: start; }
@@ -1145,7 +1172,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1145
1172
  }
1146
1173
 
1147
1174
  /* ── Phones ─────────────────────────────────────────────────────────────── */
1148
- @media (max-width: 640px) {
1175
+ @media (max-width: 639px) {
1149
1176
  .fx-main { padding-top: 20px; }
1150
1177
  .fx-title { font-size: 22px; }
1151
1178
  .fx-table thead { display: none; }
@@ -1212,7 +1239,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1212
1239
  .fx-price { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
1213
1240
  .fx-price__head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 8px 16px; }
1214
1241
  .fx-price__head .fx-search { margin: 0 0 6px; width: 260px; }
1215
- @media (max-width: 560px) { .fx-price__head .fx-search { width: 100%; } }
1242
+ @media (max-width: 639px) { .fx-price__head .fx-search { width: 100%; } }
1216
1243
  .fx-price__table th { position: sticky; top: 0; z-index: 1; }
1217
1244
  .fx-price__table td { padding-top: 9px; padding-bottom: 9px; }
1218
1245
  .fx-price__table td.fx-num, .fx-price__table th.fx-num { text-align: right; white-space: nowrap; }
@@ -1405,10 +1432,10 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1405
1432
  .fx-rule__value { display: flex; gap: 6px; align-items: center; min-width: 0; }
1406
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%; }
1407
1434
  .fx-rule__delete { justify-self: start; color: var(--t-danger); }
1408
- @media (max-width: 640px) {
1435
+ @media (max-width: 639px) {
1409
1436
  .fx-rule__condition { grid-template-columns: minmax(0, 1fr); }
1410
1437
  }
1411
- @media (max-width: 560px) {
1438
+ @media (max-width: 639px) {
1412
1439
  .fx-set__row { gap: 12px; }
1413
1440
  .fx-set__control .fx-input { width: 140px; }
1414
1441
  .fx-set__control .fx-input[type="number"] { width: 72px; }
@@ -1416,7 +1443,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1416
1443
  }
1417
1444
  .fx-multi { position: relative; width: 220px; max-width: 100%; }
1418
1445
  .fx-multi__summary { display: flex; align-items: center; list-style: none; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1419
- @media (max-width: 600px) { .fx-multi { width: 150px; } }
1446
+ @media (max-width: 639px) { .fx-multi { width: 150px; } }
1420
1447
  .fx-multi__summary::-webkit-details-marker { display: none; }
1421
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); }
1422
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; }
@@ -1504,9 +1531,19 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1504
1531
  .fx-listhead__edge { height: 0; }
1505
1532
  .fx-listhead__edge + .fx-listhead[data-sticky] { margin-top: -32px; }
1506
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; }
1507
1544
  @media (max-width: 820px) { .fx-listhead[data-sticky] { top: 60px; } }
1508
1545
  /* On a phone the head would hide too many rows: it scrolls away. */
1509
- @media (max-width: 560px) { .fx-listhead[data-sticky] { position: static; } }
1546
+ @media (max-width: 639px) { .fx-listhead[data-sticky] { position: static; } }
1510
1547
 
1511
1548
  /* The bulk action bar (bulk-bar.js): floats at the foot while rows are
1512
1549
  ticked. "N selected ×", then icon actions in groups, then More. It takes
@@ -1540,7 +1577,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1540
1577
  .fx-bulk__grid { display: grid; }
1541
1578
  .fx-bulk__grid .fx-pop__item[data-tone="danger"] { color: var(--t-danger); }
1542
1579
  .fx-bulk__none { margin: 8px 10px; color: var(--t-dim); font-size: 13.5px; }
1543
- @media (max-width: 560px) {
1580
+ @media (max-width: 639px) {
1544
1581
  .fx-bulk { bottom: 10px; padding: 4px; }
1545
1582
  .fx-bulk__count { margin-right: 2px; padding-left: 8px; }
1546
1583
  .fx-bulk__act { padding: 7px 6px; }
@@ -1602,3 +1639,63 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1602
1639
  .fx-find__input { display: none; width: min(240px, 60vw); height: 32px; padding: 0 10px; }
1603
1640
  .fx-find--open .fx-find__input { display: block; }
1604
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,8 +10,8 @@ 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 { filterCount, filterJobs, INVOICE_COLUMNS, normaliseListView } from '/assets/ui/job-view.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
15
  import { INVOICE_FILTER_FIELDS, invoiceFilterKind, invoiceValue, normaliseInvoicesView } from '/assets/ui/list-views.js';
16
16
  import { listPager, PAGE_SIZES } from '/assets/ui/pager.js';
17
17
  import { savedViews } from '/assets/ui/saved-views.js';
@@ -149,21 +149,25 @@ export async function renderInvoiceList(main, ui) {
149
149
  status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: returning ? back?.q ?? '' : '', ticked: new Set(), view: readFilters(),
150
150
  // The page of rows shown: the server filters and cuts it (GET /invoices).
151
151
  offset: returning ? back?.offset ?? 0 : 0, limit: readSize(),
152
- columns: normaliseListView(ui.me?.views?.invoices, INVOICE_COLUMNS),
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;
@@ -215,7 +219,15 @@ export async function renderInvoiceList(main, ui) {
215
219
  load();
216
220
  },
217
221
  });
218
- const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
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]));
219
231
  let body = null;
220
232
  let raw = null;
221
233
  const openTab = (id) => { state.status = id; state.offset = 0; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
@@ -225,7 +237,7 @@ export async function renderInvoiceList(main, ui) {
225
237
  // action: tick rows (the head tick takes every row shown), then Send.
226
238
  const head = listHead('Invoices', {
227
239
  actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
228
- 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,
229
241
  });
230
242
  const [title, ...top] = head.nodes;
231
243
  const frame = listFrame(main, host);
@@ -513,13 +525,24 @@ export async function renderInvoiceList(main, ui) {
513
525
  reference: showProblem
514
526
  ? { label: 'Problem', kind: 'text', cell: problemCell }
515
527
  : { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
516
- 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) },
517
530
  total: { kind: 'total', num: true, className: 'fx-strong', cell: (i) => formatCents(i.totalCents) },
518
531
  status: { kind: 'status', className: 'fx-status-cell', cell: statusCell },
519
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) },
520
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
+ }
521
544
  available = Object.keys(ALL).filter((id) => ALL[id]);
522
- 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] }));
523
546
  host.replaceChildren(heads.table({
524
547
  className: 'fx-table--rows',
525
548
  columns,
@@ -473,7 +473,14 @@ export function inOrder(columns, ids) {
473
473
  // shows the invoice's problem; Number shows on the tabs with numbers only.
474
474
  export const INVOICE_COLUMNS = Object.freeze({
475
475
  number: 'Number', client: 'Client', reference: 'Reference', date: 'Date', total: 'Total', status: 'Status', linked: 'Linked',
476
+ due: 'Due', case: 'Case', practitioner: 'Practitioner', shipped: 'Shipped',
476
477
  });
478
+ // The Invoices columns a person sees before they choose: the others (the
479
+ // invoice's due date, its job's case, practitioner and shipped date, and
480
+ // the lab's custom fields) wait in the + menu.
481
+ export const INVOICE_SHOWN = Object.freeze(['number', 'client', 'reference', 'date', 'total', 'status', 'linked']);
482
+ // A person's Invoices columns: custom fields ('field:<id>') are columns too.
483
+ export const invoiceColumnsView = (input) => normaliseListView(input, INVOICE_COLUMNS, { shown: INVOICE_SHOWN, fields: true });
477
484
  export const PRICE_COLUMNS = Object.freeze({
478
485
  code: 'Code', name: 'Name', price: 'Price', tax: 'Tax',
479
486
  });
@@ -482,12 +489,13 @@ export const RECONCILE_COLUMNS = Object.freeze({
482
489
  });
483
490
 
484
491
  // A person's view of a list with set columns (Invoices, Reconcile, Price list): the
485
- // shown columns in order (every one when none is saved) and their widths.
486
- export function normaliseListView(input, columns) {
492
+ // shown columns in order (`shown`, else every one, when none is saved) and
493
+ // their widths. With `fields`, a custom field ('field:<id>') is a column too.
494
+ export function normaliseListView(input, columns, { shown = null, fields = false } = {}) {
487
495
  const value = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
488
496
  const ids = Object.keys(columns);
489
497
  return {
490
- columns: cleanColumnIds(value.columns, (id) => ids.includes(id)) ?? ids,
498
+ columns: cleanColumnIds(value.columns, (id) => ids.includes(id) || (fields && FIELD_GROUP.test(id))) ?? [...(shown ?? ids)],
491
499
  widths: cleanWidths(value.widths),
492
500
  };
493
501
  }
package/server/ui/jobs.js CHANGED
@@ -13,7 +13,7 @@
13
13
  // or with a keyboard, the job's panel has the same moves.
14
14
 
15
15
  import { bulkBar } from '/assets/ui/bulk-bar.js';
16
- import { columnTable, onRightButton, rowCells, viewSaver } from '/assets/ui/columns.js';
16
+ import { columnTable, dateCell, fieldColumn, onRightButton, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
17
17
  import { lucide } from '/assets/ui/custom-fields.js';
18
18
  import { formatCents } from '/assets/ui/invoicing.js';
19
19
  import { jobChangeReview } from '/assets/ui/job-change.js';
@@ -24,7 +24,7 @@ import {
24
24
  COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
25
25
  normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
26
26
  } from '/assets/ui/job-view.js';
27
- import { clipLabel, initialsOf } from '/assets/ui/person.js';
27
+ import { clipLabel } from '/assets/ui/person.js';
28
28
  import { savedViews } from '/assets/ui/saved-views.js';
29
29
  import { sidecar } from '/assets/ui/sidecar.js';
30
30
  import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
@@ -52,18 +52,6 @@ const STAGE_RANK = { 'In work': 0, Drafted: 2, Problem: 2, 'Ready to invoice': 4
52
52
 
53
53
  const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short' }) : '—');
54
54
  const fullDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
55
- // A list date, short: Today, Tomorrow or Yesterday, else day/month (and
56
- // year when it is not this year).
57
- const shortDate = (iso) => {
58
- if (!iso) return '—';
59
- const at = new Date(`${iso}T00:00:00`);
60
- const now = new Date();
61
- const days = Math.round((at - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86_400_000);
62
- if (days === 0) return 'Today';
63
- if (days === 1) return 'Tomorrow';
64
- if (days === -1) return 'Yesterday';
65
- return at.toLocaleDateString('en-AU', at.getFullYear() === now.getFullYear() ? { day: '2-digit', month: '2-digit' } : { day: '2-digit', month: '2-digit', year: '2-digit' });
66
- };
67
55
  const clientOf = (job) => job.clientName ?? 'No client';
68
56
  // The one date a card shows: when the job is due, else when it was ordered.
69
57
  const dateOf = (job) => (job.dueOn ? `Due ${showDate(job.dueOn)}` : job.orderedOn ? `Ordered ${showDate(job.orderedOn)}` : job.jobDate ? showDate(job.jobDate) : null);
@@ -136,7 +124,7 @@ export async function renderJobs(main, ui) {
136
124
  // In the list, ticked rows get the same floating bar as Invoices.
137
125
  const bulk = bulkBar(ui, { label: 'Ticked jobs' });
138
126
  const frame = listFrame(main, host);
139
- main.append(...listHead('Jobs', { views: views.element, start: controls, end: [find.element, toggle] }).nodes, frame.element, bulk.element);
127
+ main.append(...listHead('Jobs', { views: views.element, controls, end: [find.element, toggle] }).nodes, frame.element, bulk.element);
140
128
 
141
129
  const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
142
130
  type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
@@ -799,7 +787,7 @@ export async function renderJobs(main, ui) {
799
787
  // The list's columns, in the order the person chose them. One column holds
800
788
  // one field; each date has its own column.
801
789
  function listColumns() {
802
- const day = (iso) => h('time', { datetime: iso ?? null, title: iso ? fullDate(iso) : null }, shortDate(iso));
790
+ const day = (iso) => dateCell(h, iso);
803
791
  const late = (job) => (isLate(job) ? h('span', { class: 'fx-job__date--late', title: 'Past due' }, day(job.dueOn)) : day(job.dueOn));
804
792
  const BUILT_IN = {
805
793
  case: { kind: 'case', className: 'fx-dim', cell: (job) => caseNumber(job) ?? '—' },
@@ -821,31 +809,12 @@ export async function renderJobs(main, ui) {
821
809
  return [client, ...state.view.columns.map((id) => {
822
810
  if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
823
811
  const field = customColumns().find((f) => fieldGroup(f.id) === id);
824
- if (!field) return null;
825
- const cell = field.type === 'person' ? (job) => personBadge(job, field)
826
- : field.type === 'date' ? (job) => day(valueOf(job, field.id)?.value ?? null) : (job) => fieldText(job, field);
827
- return { id, label: field.name, kind: field.type === 'date' ? 'date' : field.type === 'person' ? 'person' : field.type === 'number' ? 'total' : 'text', num: field.type === 'number', className: 'fx-dim', cell };
812
+ return field ? fieldColumn(h, field, { id, valueOf: (job) => valueOf(job, field.id)?.value ?? null, people: state.people }) : null;
828
813
  }).filter(Boolean).map((c) => ({ ...c, sort: () => sortButton(c.id, c.label) }))];
829
814
  }
830
815
 
831
816
  // A person custom field's value as a badge (initials), the name on hover.
832
- function personBadge(job, field) {
833
- const value = field ? valueOf(job, field.id)?.value ?? null : null;
834
- if (!value) return h('span', { class: 'fx-dim' }, '—');
835
- const name = state.people.find((p) => p.id === value)?.name ?? 'Former person';
836
- return h('span', { class: 'fx-avatar fx-avatar--sm', role: 'img', 'aria-label': name, title: name }, initialsOf(name));
837
- }
838
-
839
- // A job's custom field value as text.
840
- function fieldText(job, field) {
841
- const value = field ? valueOf(job, field.id)?.value ?? null : null;
842
- if (value == null || value === false) return '—';
843
- if (field.type === 'checkbox') return 'Yes';
844
- if (field.type === 'person') return state.people.find((p) => p.id === value)?.name ?? 'Former person';
845
- if (field.type === 'dropdown') return field.options?.find((o) => o.id === value)?.name ?? '—';
846
- if (field.type === 'date') return showDate(value);
847
- return String(value);
848
- }
817
+ const personBadge = (job, field) => personCell(h, field ? valueOf(job, field.id)?.value ?? null : null, state.people);
849
818
 
850
819
  // The list: one block per group, with the group's label over its rows.
851
820
  // A tick column at the left; a group's head has a tick for every job in
package/server/ui/kit.js CHANGED
@@ -63,22 +63,28 @@ export function pageHeader(title, { count = null, help = null, actions = [] } =
63
63
 
64
64
  // The top of a list page (Jobs, Invoices, Reconcile), laid out the same on
65
65
  // every list and in Board and List alike, so nothing moves when a person
66
- // switches: the title row, a row for saved view tabs (`views`: the page's
67
- // savedViews element, else an empty slot that stays hidden), then one toolbar with `start` at the left (tabs, Group,
68
- // Sort, Filter) and `end` at the right (search, Board/List), then the page's
69
- // `actions` (New invoice) last, as Claude's own lists do. `line` draws the
70
- // rule that tabs sit on; `sticky` keeps the toolbar at the top while the rows
71
- // scroll up under it, with a shadow once they do. Append `nodes` to the page.
72
- export function listHead(title, { count = null, help = null, actions = [], start = [], end = [], line = false, sticky = false, views: tabs = null } = {}) {
66
+ // switches: the title row, then one toolbar with `start` at the left (tabs,
67
+ // Group, Sort, Filter) and `end` at the right (search, Board/List), then the
68
+ // page's `actions` (New invoice) last, as Claude's own lists do. `controls`
69
+ // (the page's Group, Sort and Filter) sit with the saved view tabs at their
70
+ // right (`views`: the page's savedViews element, else an empty slot that
71
+ // stays hidden), at the end of `start`, or of `end` when `controlsAt` is
72
+ // 'end'. `line` draws the rule that tabs sit on; `sticky` keeps the toolbar
73
+ // at the top while the rows scroll up under it, with a shadow once they do.
74
+ // Append `nodes` to the page.
75
+ export function listHead(title, { count = null, help = null, actions = [], start = [], end = [], controls = null, controlsAt = 'start', line = false, sticky = false, views: tabs = null } = {}) {
73
76
  const views = tabs ?? h('div', { class: 'fx-views', role: 'group', 'aria-label': 'Views', hidden: true });
77
+ const tools = h('div', { class: 'fx-toolbar__tools' }, controls, views);
78
+ const left = controlsAt === 'end' ? [start].flat() : [start, tools].flat();
79
+ const right = controlsAt === 'end' ? [tools, end].flat() : [end].flat();
74
80
  const bar = h('div', { class: 'fx-listhead', 'data-sticky': sticky },
75
- h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, start, h('div', { class: 'fx-toolbar__end' }, end, actions)));
81
+ h('div', { class: `fx-toolbar${line ? '' : ' fx-toolbar--plain'}` }, left, h('div', { class: 'fx-toolbar__end' }, right, actions)));
76
82
  const edge = sticky ? h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' }) : null;
77
83
  if (edge && typeof IntersectionObserver === 'function') {
78
84
  const top = window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1;
79
85
  new IntersectionObserver(([entry]) => bar.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${top}px 0px 0px 0px` }).observe(edge);
80
86
  }
81
- return { nodes: [pageHeader(title, { count, help }), views, edge, bar].filter(Boolean), views, bar };
87
+ return { nodes: [pageHeader(title, { count, help }), edge, bar].filter(Boolean), views, bar };
82
88
  }
83
89
 
84
90
  // The window a list or board sits in under listHead, as HubSpot's: on a