@arcforgelabs/flow 2026.10.51 → 2026.10.53-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/npm-shrinkwrap.json +2 -2
  2. package/package.json +1 -1
  3. package/server/api/captures.js +26 -4
  4. package/server/api/invoicing.js +1 -1
  5. package/server/app.js +2 -0
  6. package/server/auth/people.js +3 -3
  7. package/server/core/ai.js +2 -1
  8. package/server/core/capture-match.js +158 -0
  9. package/server/core/captures.js +141 -7
  10. package/server/core/invoice-rules.js +16 -5
  11. package/server/core/reader.js +1 -10
  12. package/server/core/sending.js +7 -3
  13. package/server/db/migrations/0024_rule_icon.sql +5 -0
  14. package/server/db/migrations/meta/0024_snapshot.json +4703 -0
  15. package/server/db/migrations/meta/_journal.json +8 -1
  16. package/server/db/schema.js +3 -0
  17. package/server/integrations/seazona/history/backfill.js +39 -9
  18. package/server/integrations/seazona/index.js +1 -12
  19. package/server/integrations/seazona/lanes.js +44 -27
  20. package/server/integrations/seazona/stage-moves.js +3 -13
  21. package/server/integrations/sentry/index.js +6 -6
  22. package/server/integrations/setup.js +1 -0
  23. package/server/integrations/xero/drafts.js +27 -13
  24. package/server/ui/app-page.js +1 -1
  25. package/server/ui/app.js +61 -20
  26. package/server/ui/capture-fields.js +17 -0
  27. package/server/ui/captures.js +289 -0
  28. package/server/ui/columns.js +68 -3
  29. package/server/ui/custom-fields.js +3 -9
  30. package/server/ui/flow.css +94 -18
  31. package/server/ui/invoice-rules.js +10 -2
  32. package/server/ui/invoicing-settings.js +2 -8
  33. package/server/ui/invoicing.js +12 -8
  34. package/server/ui/job-view.js +6 -3
  35. package/server/ui/jobs.js +40 -117
  36. package/server/ui/kit.js +86 -1
  37. package/server/ui/list-views.js +47 -3
  38. package/server/ui/mobile.js +45 -5
  39. package/server/ui/nav-pins.js +4 -2
  40. package/server/ui/price-list.js +138 -69
  41. package/server/ui/routes.js +4 -1
  42. package/server/ui/saved-views.js +2 -0
  43. package/server/ui/view-bar.js +61 -2
@@ -199,7 +199,9 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
199
199
  .fx-tip {
200
200
  position: fixed; top: 0; left: 0; z-index: 1000; max-width: 260px; padding: 4px 8px;
201
201
  border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong);
202
- font: 500 12px/1.3 var(--t-body); pointer-events: none; white-space: nowrap;
202
+ font: 500 12px/1.3 var(--t-body); pointer-events: none;
203
+ /* A short name keeps one line; a long help text wraps inside the box. */
204
+ width: max-content; overflow-wrap: anywhere;
203
205
  box-shadow: var(--t-shadow), 0 4px 12px rgba(0, 0, 0, .12);
204
206
  }
205
207
 
@@ -382,7 +384,7 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
382
384
 
383
385
  /* One integration's page: hero, then the setup beside its facts. */
384
386
  .fx-int-page { display: grid; gap: 24px; }
385
- .fx-int-hero { display: flex; align-items: center; gap: 14px; min-width: 0; }
387
+ .fx-int-hero { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; min-width: 0; }
386
388
  .fx-int-hero .fx-title { min-width: 0; font-size: 28px; overflow-wrap: anywhere; }
387
389
  .fx-int-hero__actions { position: relative; display: flex; align-items: center; gap: 10px; margin-left: auto; }
388
390
  .fx-int-hero__actions .fx-progress { left: auto; right: 0; }
@@ -399,6 +401,9 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
399
401
  .fx-int-fold > summary::after { content: ''; width: 8px; height: 8px; margin-left: 2px; border: solid currentColor; border-width: 0 2px 2px 0; transform: rotate(-45deg); transition: transform 120ms var(--fx-ease); }
400
402
  .fx-int-fold[open] > summary::after { transform: rotate(45deg) translateY(-2px); }
401
403
  .fx-int-fold:not([open]) { gap: 0; }
404
+ /* A fold inside a section (Settings, Advanced). */
405
+ .fx-int-fold--sub { display: grid; gap: 14px; }
406
+ .fx-int-fold--sub > summary { color: var(--t-dim); font: 600 14.5px/1.3 var(--t-body); }
402
407
  .fx-steps { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; counter-reset: step; }
403
408
  .fx-steps li { position: relative; padding-left: 38px; min-height: 26px; counter-increment: step; font-size: 14.5px; line-height: 1.55; }
404
409
  .fx-steps li::before {
@@ -561,7 +566,10 @@ a.fx-strong:hover { text-decoration: underline; }
561
566
  .fx-totals dd { margin: 0; text-align: right; font-family: var(--t-data, var(--t-body)); font-variant-numeric: tabular-nums; color: var(--t-strong); }
562
567
  .fx-totals__grand { padding-top: 8px; border-top: 1px solid var(--fx-line); font-size: 17px; font-weight: 700; color: var(--t-strong) !important; }
563
568
  .fx-doc__meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin: 0; padding: 20px; border-bottom: 1px solid var(--fx-line-soft); }
564
- .fx-doc__meta dd { display: grid; margin: 4px 0 0; color: var(--t-strong); font-weight: 600; }
569
+ .fx-doc__meta dd { display: grid; margin: 4px 0 0; color: var(--t-strong); font-weight: 600; overflow-wrap: anywhere; }
570
+ /* A long value stays in its column: text wraps, an email cuts with "…" (full value on hover). */
571
+ .fx-doc__meta > div, .fx-doc__meta dd > * { min-width: 0; }
572
+ .fx-doc__meta dd > .fx-dim { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
565
573
  @media (max-width: 1320px) {
566
574
  .fx-invoice__grid { grid-template-columns: minmax(0, 1fr); }
567
575
  .fx-invoice__aside { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
@@ -747,7 +755,7 @@ a.fx-strong:hover { text-decoration: underline; }
747
755
  .fx-table__group-row .fx-board__sum { margin-left: 0; }
748
756
  /* The Jobs list's group head folds its rows (jobs.js, list): a click on the
749
757
  head or its chevron. */
750
- .fx-table--jobs .fx-table__group { cursor: pointer; }
758
+ .fx-table__group { cursor: pointer; }
751
759
  .fx-table--jobs .fx-table__group td.fx-tick-cell { padding-right: 0; }
752
760
  .fx-table__fold { display: inline-flex; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 2px 0; border: 0; background: none; color: var(--t-dim); font: inherit; cursor: pointer; }
753
761
  .fx-table__fold > svg { flex: none; transition: transform .15s; }
@@ -1064,6 +1072,39 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
1064
1072
  .fx-task__activity li::after { content: ""; position: absolute; left: 5.5px; top: 16px; bottom: 0; width: 1px; background: var(--fx-line); }
1065
1073
  .fx-task__activity li:last-child::after { display: none; }
1066
1074
  .fx-task__activity time { display: block; margin-top: 1px; color: var(--t-mute); font-size: 11.5px; }
1075
+ /* Capture (server/ui/captures.js): the tray rows and the result sheet. The
1076
+ sheet reuses the job panel (.fx-task), so a phone gets the same full page. */
1077
+ .fx-captures { margin: 0; padding: 0; list-style: none; }
1078
+ .fx-captures__row {
1079
+ display: flex; align-items: center; gap: 12px; width: 100%; min-height: 56px; padding: 8px 16px;
1080
+ border: 0; border-bottom: 1px solid var(--fx-line-soft); background: transparent; color: var(--t-text);
1081
+ font: inherit; text-align: left; cursor: pointer;
1082
+ }
1083
+ .fx-captures__row:hover:not(:disabled) { background: var(--t-accent-bg); }
1084
+ .fx-captures__row:focus-visible { box-shadow: var(--fx-focus); }
1085
+ .fx-captures__row:disabled { cursor: default; }
1086
+ .fx-captures__title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--t-strong); }
1087
+ .fx-dialog.fx-capture { width: min(720px, calc(100vw - 32px)); }
1088
+ .fx-capture .fx-task__grid { grid-template-columns: minmax(0, 1fr); }
1089
+ .fx-capture__photo { display: block; width: 100%; max-height: 32vh; object-fit: contain; border-radius: var(--t-radius); background: var(--t-bg); }
1090
+ .fx-capture__changes { display: grid; margin-top: 12px; }
1091
+ .fx-capture__change {
1092
+ display: grid; grid-template-columns: minmax(80px, 1fr) minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px;
1093
+ min-height: 44px; padding: 4px 8px; border-bottom: 1px solid var(--fx-line-soft);
1094
+ }
1095
+ .fx-capture__field { color: var(--t-dim); }
1096
+ .fx-capture__where { display: block; font-size: 12px; color: var(--t-dim); }
1097
+ .fx-capture__from { color: var(--t-dim); text-decoration: line-through; overflow-wrap: anywhere; }
1098
+ .fx-capture__arrow { color: var(--t-dim); }
1099
+ .fx-capture__to { color: var(--t-strong); font-weight: 600; overflow-wrap: anywhere; }
1100
+ .fx-capture__change[data-unsure="true"] { background: var(--t-warn-bg); }
1101
+ .fx-capture__change[data-unsure="true"] .fx-capture__to { color: var(--t-warn); }
1102
+ /* Phones and tablets: Capture and the sheet's controls are thumb sized. */
1103
+ @media (max-width: 1023px) {
1104
+ .fx-dialog.fx-capture .fx-task__actions .fx-btn { min-height: 44px; }
1105
+ .fx-dialog.fx-capture .fx-task__tools .fx-btn--icon { width: 44px; height: 44px; }
1106
+ }
1107
+
1067
1108
  /* A record's phone bar and foot. Hidden above a phone, so the desk panel and
1068
1109
  the invoice header stay as they are. */
1069
1110
  .fx-record, .fx-record__foot { display: none; }
@@ -1246,6 +1287,9 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1246
1287
  .fx-setting__head { display: flex; align-items: center; gap: 2px; min-width: 0; }
1247
1288
  .fx-setting__head .fx-help { margin: -4px 0; border: 0; background: none; cursor: pointer; }
1248
1289
  .fx-setting__hint { margin: 0; color: var(--t-dim); font-size: 13px; line-height: 1.45; }
1290
+ .fx-history__status { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
1291
+ .fx-history__day { width: 160px; flex: none; }
1292
+ .fx-history__bar { width: 100%; height: 8px; accent-color: var(--t-brand); }
1249
1293
  .fx-setting[data-tone="danger"] .fx-setting__hint { color: var(--t-danger); }
1250
1294
  .fx-setting__control { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
1251
1295
  .fx-setting__control > .fx-input, .fx-setting__control > .fx-select, .fx-setting__stack { width: 320px; max-width: 100%; }
@@ -1457,6 +1501,8 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1457
1501
  }
1458
1502
  /* Line 2 is 20px; its cells centre on it. */
1459
1503
  [data-phone-layout="two-line"] .fx-table td[data-phone-slot="meta"] { display: flex; align-items: center; overflow: visible; }
1504
+ /* Plain text on line 2 ends in "…" instead of running under the next cell. */
1505
+ [data-phone-layout="two-line"] .fx-table td[data-phone-slot="meta"]:not(:has(*)) { display: block; overflow: hidden; line-height: 20px; }
1460
1506
  [data-phone-layout="two-line"] td.fx-c-stage,
1461
1507
  [data-phone-layout="two-line"] td.fx-c-status { order: 4; color: inherit; }
1462
1508
  [data-phone-layout="two-line"] .fx-stage { padding-block: 0; line-height: 16px; }
@@ -1506,20 +1552,9 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1506
1552
  .fx-set__title { margin: 8px 0 4px; color: var(--t-strong); font: 600 15px/1.3 var(--t-body); }
1507
1553
  .fx-set__hint { color: var(--t-dim); font-size: 13px; line-height: 1.35; }
1508
1554
  .fx-set + .fx-set { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--fx-line); }
1509
- /* Price list (price-list.js): one plain table of Flow's codes. */
1510
- .fx-price { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
1511
- .fx-price__head { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 8px 16px; }
1512
- .fx-price__head .fx-search { margin: 0 0 6px; width: 260px; }
1513
- @media (max-width: 639px) {
1514
- .fx-price__head .fx-search { width: 100%; }
1515
- .fx-price__settings .fx-select { min-height: 44px; }
1516
- }
1517
- .fx-price__table th { position: sticky; top: 0; z-index: 1; }
1518
- .fx-price__table td { padding-top: 9px; padding-bottom: 9px; }
1555
+ /* Price list (price-list.js): the codes' table, in the list window. */
1519
1556
  .fx-price__table td.fx-num, .fx-price__table th.fx-num { text-align: right; white-space: nowrap; }
1520
1557
  .fx-price__code { width: 1%; white-space: nowrap; font-family: var(--t-data); font-size: 13px; font-weight: 600; color: var(--t-strong); }
1521
- .fx-price__settings { margin-top: 12px; }
1522
- .fx-ph__actions .fx-tabs__count[data-tone="warn"], .fx-btn .fx-tabs__count[data-tone="warn"] { margin-left: 8px; background: var(--t-warn-bg); color: var(--t-warn); }
1523
1558
  /* Price list › Reconcile (price-reconcile.js): Xero › Import's two views. */
1524
1559
  .fx-prc .fx-pager { margin-bottom: 4px; }
1525
1560
  .fx-prc__finder { display: flex; flex-direction: column; gap: 4px; }
@@ -1559,6 +1594,40 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1559
1594
  .fx-cf__choice[aria-checked="true"] { background: var(--fx-hover); color: var(--t-strong); }
1560
1595
  .fx-cf__choice > :first-child { justify-self: start; }
1561
1596
  .fx-cf__choice .fx-lucide { width: 15px; height: 15px; color: var(--t-accent); }
1597
+ /* Icon picker (kit.js iconPicker): the button shows the icon; the panel is a
1598
+ search box over a grid of icons. A phone gets a sheet at the foot. */
1599
+ .fx-icons { display: inline-flex; }
1600
+ .fx-icons__button {
1601
+ display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 8px 0 10px;
1602
+ border: 1px solid var(--t-control); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong); cursor: pointer;
1603
+ }
1604
+ .fx-icons__button > .fx-lucide { width: 18px; height: 18px; }
1605
+ .fx-icons__button > .fx-lucide + .fx-lucide { width: 14px; height: 14px; color: var(--t-dim); }
1606
+ .fx-icons__button:hover { background: var(--fx-hover); }
1607
+ .fx-icons__button:focus-visible { box-shadow: var(--fx-focus); outline: none; }
1608
+ .fx-pop.fx-icons__panel { width: 316px; max-width: calc(100vw - 16px); padding: 8px; overflow: hidden; }
1609
+ .fx-icons__panel:popover-open { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 8px; }
1610
+ .fx-icons__panel .fx-input { min-height: 32px; font-size: 13px; }
1611
+ .fx-icons__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(36px, 1fr)); gap: 2px; max-height: 248px; overflow-y: auto; }
1612
+ .fx-icons__grid > p { grid-column: 1 / -1; margin: 8px 4px; }
1613
+ .fx-icons__cell {
1614
+ display: inline-grid; place-items: center; aspect-ratio: 1; min-width: 36px; border: 0; border-radius: var(--t-radius);
1615
+ background: none; color: var(--t-text); cursor: pointer;
1616
+ }
1617
+ .fx-icons__cell .fx-lucide { width: 18px; height: 18px; }
1618
+ .fx-icons__cell:hover, .fx-icons__cell:focus-visible { background: var(--fx-hover); color: var(--t-strong); outline: none; }
1619
+ .fx-icons__cell[aria-pressed="true"] { background: var(--t-accent-bg); color: var(--t-accent); box-shadow: inset 0 0 0 1px var(--t-accent-brd, var(--t-accent)); }
1620
+ .fx-icons__foot { display: flex; justify-content: flex-end; }
1621
+ .fx-icons__foot > [hidden] { display: none; }
1622
+ .fx-pop.fx-icons__panel[data-phone-layout="sheet"] {
1623
+ inset: auto 0 0 0; width: 100%; max-width: 100%; max-height: min(70vh, 100%); padding: 12px 12px max(12px, env(safe-area-inset-bottom));
1624
+ border-radius: var(--t-radius-lg) var(--t-radius-lg) 0 0;
1625
+ }
1626
+ .fx-icons__panel[data-phone-layout="sheet"] .fx-input { min-height: 44px; font-size: 16px; }
1627
+ .fx-icons__panel[data-phone-layout="sheet"] .fx-icons__grid { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); max-height: none; }
1628
+ .fx-icons__panel[data-phone-layout="sheet"] .fx-icons__cell { min-width: 44px; }
1629
+ .fx-icons__panel[data-phone-layout="sheet"] .fx-icons__cell .fx-lucide { width: 22px; height: 22px; }
1630
+ .fx-icons__panel[data-phone-layout="sheet"] .fx-icons__foot .fx-btn { min-height: 44px; }
1562
1631
  /* Custom fields manager (custom-fields.js), like ClickUp's Custom Field
1563
1632
  Manager: a toolbar, then one table of type groups that fold. */
1564
1633
  .fx-lucide { display: inline-block; flex: none; width: 16px; height: 16px; background: currentColor; -webkit-mask: var(--icon) center / contain no-repeat; mask: var(--icon) center / contain no-repeat; }
@@ -1614,7 +1683,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1614
1683
  .fx-cfm__create .fx-lucide { width: 14px; height: 14px; }
1615
1684
  .fx-cfm__empty { margin: 0; padding: 20px 16px; color: var(--t-dim); }
1616
1685
  .fx-cfm > .fx-notice { margin: 12px 16px 0; }
1617
- .fx-cfm__foot { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 13px; }
1618
1686
  .fx-pop__item:disabled { opacity: .5; cursor: default; }
1619
1687
  .fx-pop__item[data-tone="danger"], .fx-pop__item[data-tone="danger"] .fx-pop__tick { color: var(--t-danger); }
1620
1688
  .fx-pop__item .fx-lucide { width: 15px; height: 15px; }
@@ -1691,6 +1759,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1691
1759
  .fx-cfm__head, .fx-cfm__row { padding-left: 16px; }
1692
1760
  .fx-cfm__create { padding-left: 16px; }
1693
1761
  .fx-cfm__search { flex: 1 1 100%; width: auto; }
1762
+ .fx-cfm__bar :is(.fx-cfm__search, .fx-cfm__tool, .fx-btn) { height: auto; min-height: 44px; }
1694
1763
  .fx-cfm__edit { display: none !important; }
1695
1764
  /* Custom fields uses the same phone slots as a column list: name and the
1696
1765
  menu on line 1, up to three facts on line 2, 56px. */
@@ -1714,6 +1783,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1714
1783
  .fx-rule__footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; }
1715
1784
  .fx-dialog:has(.fx-rule__dialog) { width: min(680px, calc(100vw - 32px)); }
1716
1785
  .fx-rule__dialog .fx-btn--sm { justify-self: start; }
1786
+ .fx-rule__name { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; }
1717
1787
  .fx-rule__conditions { display: grid; gap: 8px; }
1718
1788
  .fx-rule__condition { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.4fr) auto; gap: 8px; align-items: start; }
1719
1789
  .fx-rule__condition + .fx-rule__condition { padding-top: 8px; border-top: 1px solid var(--fx-line); }
@@ -1830,10 +1900,13 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1830
1900
  .fx-listhead > .fx-toolbar > .fx-tabs button { padding-block: 9px; }
1831
1901
  /* Every control in the toolbar is one height (--fx-control-h). Under a
1832
1902
  tab line, the controls keep 8px clear of the line; only the tabs sit on it. */
1903
+ /* A finger needs 44px: on a phone or tablet every list's toolbar controls
1904
+ are that tall (the ListHead card's touch rule). */
1905
+ @media (max-width: 1023px) { .fx-listhead { --fx-control-h: 44px; } }
1833
1906
  .fx-listhead .fx-toolbar :is(.fx-btn, .fx-chip, .fx-views__add, .fx-views__tab) { min-height: var(--fx-control-h); }
1834
1907
  .fx-listhead .fx-toolbar .fx-btn:not(.fx-btn--sm) { padding-block: 0; font-size: 14px; }
1835
1908
  .fx-listhead .fx-toolbar :is(.fx-btn, .fx-chip) { height: var(--fx-control-h); }
1836
- .fx-listhead .fx-toolbar .fx-find__button { width: var(--fx-control-h); height: var(--fx-control-h); }
1909
+ .fx-listhead .fx-toolbar :is(.fx-find__button, .fx-btn--icon) { width: var(--fx-control-h); height: var(--fx-control-h); }
1837
1910
  .fx-listhead .fx-toolbar .fx-find__input { height: var(--fx-control-h); }
1838
1911
  .fx-listhead .fx-toolbar .fx-seg { padding: 2px; }
1839
1912
  .fx-listhead .fx-toolbar .fx-seg__item { min-height: calc(var(--fx-control-h) - 4px); }
@@ -1846,6 +1919,9 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1846
1919
  .fx-listhead[data-sticky] { position: static; margin-top: 0; padding-top: 0; }
1847
1920
  .fx-listhead__edge + .fx-listhead[data-sticky] { margin-top: -8px; }
1848
1921
  .fx-alerts { margin-bottom: 4px; }
1922
+ /* The tools have their own row under the tabs: no gap under them, so the
1923
+ 44px controls still leave seven rows on a small phone. */
1924
+ .fx-listhead > .fx-toolbar:not(.fx-toolbar--plain) > :not(.fx-tabs) { margin-bottom: 0; }
1849
1925
  }
1850
1926
 
1851
1927
  /* The bulk action bar (bulk-bar.js): floats at the foot while rows are
@@ -18,6 +18,9 @@ const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft', pending:
18
18
  const NO_VALUE = ['is_empty', 'is_not_empty', 'has_number', 'number_in_words', 'one_word'];
19
19
  const MANY = ['is_one_of', 'is_not_one_of'];
20
20
  const TYPED = ['contains', 'does_not_contain', 'starts_with', 'does_not_start_with'];
21
+ // A rule's icon: its own (rule.icon), else the mark of what it does.
22
+ const KIND_MARKS = { pending: 'flag', wait: 'clock', skip: 'ban' };
23
+ export const ruleMark = (rule) => rule.icon ?? KIND_MARKS[rule.action.kind] ?? 'list-checks';
21
24
 
22
25
  // A rule as one short line: "Job age (days) is more than 90 · Wait for a person".
23
26
  export function ruleSummary(rule, meta) {
@@ -42,7 +45,7 @@ export function ruleSummary(rule, meta) {
42
45
  }
43
46
 
44
47
  export function rulesSection(ui, title = 'Rules') {
45
- const { h, flow, toast, notice, dialog, me, settingRow } = ui;
48
+ const { h, flow, toast, notice, dialog, me, settingRow, iconPicker } = ui;
46
49
  const disabled = !['owner', 'admin'].includes(me?.person.role);
47
50
  const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
48
51
  let meta;
@@ -153,6 +156,10 @@ export function rulesSection(ui, title = 'Rules') {
153
156
  const action = h('select', { class: 'fx-select', 'aria-label': 'Then' },
154
157
  Object.entries(ACTION_LABELS).map(([value, label]) => h('option', { value }, label)));
155
158
  action.value = draft?.action.kind ?? 'wait';
159
+ // With no icon of its own, the rule shows the mark of its Then.
160
+ const kindMark = () => ruleMark({ action: { kind: action.value } });
161
+ const icon = iconPicker({ value: draft?.icon ?? null, fallback: kindMark(), label: 'Icon' });
162
+ action.addEventListener('change', () => icon.fallback(kindMark()));
156
163
  // A pending rule's note: what a person must fix. It shows on the draft.
157
164
  const note = h('input', {
158
165
  class: 'fx-input', maxlength: '200', value: draft?.action.note ?? '', 'aria-label': 'Note',
@@ -191,7 +198,7 @@ export function rulesSection(ui, title = 'Rules') {
191
198
  const data = await dialog({
192
199
  title: rule ? 'Edit rule' : 'New rule',
193
200
  body: h('div', { class: 'fx-fields fx-rule__dialog' },
194
- label('Name', name),
201
+ h('div', { class: 'fx-rule__name' }, label('Icon', icon.element), label('Name', name)),
195
202
  label('When', h('span', { class: 'fx-dim' }, 'A job is ready to invoice. A pending rule also checks its draft and the Reference.')),
196
203
  label('If all of these match', list, more),
197
204
  label('Then', action),
@@ -207,6 +214,7 @@ export function rulesSection(ui, title = 'Rules') {
207
214
  if (!data) return null;
208
215
  const payload = {
209
216
  name: name.value,
217
+ icon: icon.value(),
210
218
  conditions: editors.map((e) => e.read()),
211
219
  action: action.value === 'pending' ? { kind: 'pending', note: note.value } : { kind: action.value },
212
220
  };
@@ -10,7 +10,7 @@
10
10
  // or admin changes them. The full rules are in docs/INVOICING_RULES.md.
11
11
  // app.js passes in its shared pieces as `ui`.
12
12
 
13
- import { rulesSection } from '/assets/ui/invoice-rules.js';
13
+ import { ruleMark, rulesSection } from '/assets/ui/invoice-rules.js';
14
14
  import { lineMappingSection } from '/assets/ui/line-mappings.js';
15
15
 
16
16
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/INVOICING_RULES.md';
@@ -27,12 +27,6 @@ const PARTS = [
27
27
  { key: 'price-codes', title: 'Price codes', mount: (ui) => lineMappingSection(ui) },
28
28
  ];
29
29
 
30
- // What a rule does, as its card's mark: Pending, Waits, Never drafted.
31
- const KIND = {
32
- pending: { mark: 'flag' },
33
- wait: { mark: 'clock' },
34
- skip: { mark: 'ban' },
35
- };
36
30
  const SHOWN = 6;
37
31
 
38
32
  export function renderInvoicingSettings(main, ui, id) {
@@ -60,7 +54,7 @@ export function renderInvoicingSettings(main, ui, id) {
60
54
  return;
61
55
  }
62
56
  const ruleCard = (rule) => tileCard({
63
- row: true, href: page('invoices'), mark: (KIND[rule.action.kind] ?? { mark: 'list-checks' }).mark, title: rule.name,
57
+ row: true, href: page('invoices'), mark: ruleMark(rule), title: rule.name,
64
58
  meta: rule.enabled ? 'On' : 'Off',
65
59
  });
66
60
  const waiting = labels.filter((l) => l.unmapped > 0).length;
@@ -641,16 +641,20 @@ export async function renderInvoiceList(main, ui) {
641
641
  };
642
642
  const href = (invoice) => `/invoices/${invoice.id}`;
643
643
  const ALL = {
644
- number: numbered ? { kind: 'number', cell: (i) => link(href(i), i.number ?? 'Draft') } : null,
645
- client: { kind: 'client', phone: 'title', cell: (i) => (numbered ? (i.contactName ?? '—') : link(href(i), i.contactName ?? 'No client')) },
644
+ // The client is the row's link: on a phone it is the title, with a 44px hit area.
645
+ number: numbered ? { kind: 'number', phone: 'meta', className: 'fx-strong', cell: (i) => i.number ?? 'Draft' } : null,
646
+ client: { kind: 'client', phone: 'title', cell: (i) => link(href(i), i.contactName ?? 'No client') },
647
+ // A phone row: the client (and number) and total, then the problem,
648
+ // the reference or the status, and the date. A tab without numbers
649
+ // holds one status: its tab says it.
646
650
  reference: showProblem
647
- ? { label: 'Problem', kind: 'text', cell: problemCell }
648
- : { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
649
- date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.issueDate ?? i.createdAt?.slice(0, 10)) },
651
+ ? { label: 'Problem', kind: 'text', phone: 'meta', cell: problemCell }
652
+ : { kind: 'text', className: 'fx-dim', phone: numbered ? 'hide' : 'meta', cell: (i) => i.reference ?? '—' },
653
+ date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', phone: 'meta', cell: (i) => dateCell(h, i.issueDate ?? i.createdAt?.slice(0, 10)) },
650
654
  due: { kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.dueDate) },
651
655
  total: { kind: 'total', num: true, className: 'fx-strong', cell: (i) => formatCents(i.totalCents) },
652
- status: { kind: 'status', className: 'fx-status-cell', cell: statusCell },
653
- linked: linked ? { kind: 'link', cell: linkCell } : null,
656
+ status: { kind: 'status', className: 'fx-status-cell', ...(numbered ? {} : { phone: 'hide' }), cell: statusCell },
657
+ linked: linked ? { kind: 'link', phone: 'hide', cell: linkCell } : null,
654
658
  // The invoice's job (from the work system): its case, practitioner and
655
659
  // the day it shipped.
656
660
  case: { kind: 'case', className: 'fx-dim', cell: (i) => (i.job ? caseNumber(i.job) ?? '—' : '—') },
@@ -1449,7 +1453,7 @@ function issuedView(invoice, ui, context, redraw) {
1449
1453
  h('div', { class: 'fx-invoice__main' },
1450
1454
  h('section', { class: 'fx-card fx-doc' },
1451
1455
  h('dl', { class: 'fx-doc__meta' },
1452
- h('div', {}, h('dt', { class: 'fx-label' }, 'Bill to'), h('dd', {}, invoice.contact?.name ?? '—', invoice.contact?.email ? h('span', { class: 'fx-dim' }, invoice.contact.email) : null)),
1456
+ h('div', {}, h('dt', { class: 'fx-label' }, 'Bill to'), h('dd', {}, invoice.contact?.name ?? '—', invoice.contact?.email ? h('span', { class: 'fx-dim', title: invoice.contact.email }, invoice.contact.email) : null)),
1453
1457
  h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
1454
1458
  h('div', {}, h('dt', { class: 'fx-label' }, 'Date'), h('dd', {}, showDate(invoice.issueDate))),
1455
1459
  h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
@@ -273,7 +273,10 @@ function inDateValue(day, want, today, late) {
273
273
  // person looking (for "Me" in a person filter); `today` is yyyy-mm-dd.
274
274
  export function matchesFilter(filter, value, { today, me = null, late = null, kind = null } = {}) {
275
275
  const want = filter.value;
276
- const empty = value == null || value === '' || value === false;
276
+ // A field can hold many choices (the Price list's Linked): Is keeps a row
277
+ // with any of the wanted ones, Is not a row with none.
278
+ const empty = value == null || value === '' || value === false || (Array.isArray(value) && !value.length);
279
+ const holds = (w) => (Array.isArray(value) ? value.includes(w) : value === w);
277
280
  const day = typeof value === 'string' && ISO_DAY.test(value.slice(0, 10)) ? value.slice(0, 10) : null;
278
281
  if (kind === 'date' && ['is', 'not'].includes(filter.op)) {
279
282
  const inside = inDateValue(day, want, today, late);
@@ -284,8 +287,8 @@ export function matchesFilter(filter, value, { today, me = null, late = null, ki
284
287
  case 'unset': return empty;
285
288
  case 'yes': return value === true;
286
289
  case 'no': return value !== true;
287
- case 'is': return Array.isArray(want) ? want.map((w) => (w === 'me' ? me : w)).includes(value) : !empty && text(value) === text(want).trim();
288
- case 'not': return !want.map((w) => (w === 'me' ? me : w)).includes(value);
290
+ case 'is': return Array.isArray(want) ? want.map((w) => (w === 'me' ? me : w)).some(holds) : !empty && text(value) === text(want).trim();
291
+ case 'not': return !want.map((w) => (w === 'me' ? me : w)).some(holds);
289
292
  case 'has': return text(value).includes(text(want).trim());
290
293
  case 'hasnt': return !text(value).includes(text(want).trim());
291
294
  case 'eq': return typeof value === 'number' && value === want;
package/server/ui/jobs.js CHANGED
@@ -13,7 +13,8 @@
13
13
  // long press opens Move to. 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, dateCell, fieldColumn, onRightButton, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
16
+ import { captureButton } from '/assets/ui/captures.js';
17
+ import { columnTable, dateCell, fieldColumn, groupHead, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
17
18
  import { lucide } from '/assets/ui/custom-fields.js';
18
19
  import { formatCents } from '/assets/ui/invoicing.js';
19
20
  import { jobChangeReview } from '/assets/ui/job-change.js';
@@ -92,7 +93,6 @@ function saveSide(tab) {
92
93
 
93
94
  const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6v3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r=".9" fill="currentColor"/></svg>';
94
95
  const PERSON_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="5.5" r="3" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 14c.6-2.8 2.8-4.3 5.5-4.3s4.9 1.5 5.5 4.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
95
- const CHEVRON_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3.5 4.5 4.5L6 12.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
96
96
  const CALENDAR_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
97
97
 
98
98
  export async function renderJobs(main, ui) {
@@ -126,7 +126,9 @@ export async function renderJobs(main, ui) {
126
126
  // In the list, ticked rows get the same floating bar as Invoices.
127
127
  const bulk = bulkBar(ui, { label: 'Ticked jobs' });
128
128
  const frame = listFrame(main, host);
129
- main.append(...listHead('Jobs', { views: views.element, controls, end: [find.element, toggle] }).nodes, frame.element, bulk.element);
129
+ // A form photo from the Jobs page; Capture lists it with its result.
130
+ const shoot = captureButton(ui, { icon: true, added: () => ui.toast('Sent to Capture.', 'success') });
131
+ main.append(...listHead('Jobs', { views: views.element, controls, end: [find.element, toggle, ...shoot.nodes] }).nodes, frame.element, bulk.element);
130
132
 
131
133
  const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
132
134
  type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
@@ -170,7 +172,7 @@ export async function renderJobs(main, ui) {
170
172
  }
171
173
 
172
174
  // The chips (view-bar.js), shared with every list page.
173
- const { menu, option, title, choose, bin, addButton, rowsFoot, clearAll, filterMenu: buildFilterMenu } = viewBar(h);
175
+ const { menu, option, groupMenu: buildGroupMenu, sortMenu: buildSortMenu, filterMenu: buildFilterMenu } = viewBar(h);
174
176
  const DATE_WORDS = ['Oldest first', 'Newest first'];
175
177
  const NUMBER_WORDS = ['Low to high', 'High to low'];
176
178
  const DIR_WORDS = { total: NUMBER_WORDS, due: DATE_WORDS, ordered: DATE_WORDS, shipped: DATE_WORDS };
@@ -178,54 +180,31 @@ export async function renderJobs(main, ui) {
178
180
  const dirWords = (by) => DIR_WORDS[by] ?? TYPE_WORDS[fieldById(fieldIdOf(by))?.type] ?? ['A to Z', 'Z to A'];
179
181
  const fieldById = (id) => state.fields.find((f) => f.id === id) ?? null;
180
182
 
181
- // ── Group by ──
182
- // One row: what to group by, the group order, and a bin that takes the
183
- // groups away (the list only: the board always has lanes).
183
+ // ── Group by, Sort ── (view-bar.js). The board always has lanes, so only
184
+ // the list can take the groups away. In the list, a column's head sorts
185
+ // too.
184
186
  const groupOptions = () => [
185
187
  ...Object.entries(GROUPS).filter(([id]) => id !== 'none').map(([value, label]) => ({ value, label })),
186
188
  { group: 'Custom fields', options: groupable().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
187
189
  ];
188
- const groupMenu = menu(ICONS.group, (redraw) => {
189
- const list = state.view.layout === 'list';
190
- const group = state.view.group === 'none' && !list ? 'status' : state.view.group;
191
- const none = group === 'none';
192
- return [
193
- title('Group by'),
194
- h('div', { class: 'fx-rows' }, h('div', { class: 'fx-rows__row fx-rows__row--group' },
195
- choose('Group by', none ? '' : group, [...(none ? [{ value: '', label: 'Choose…', disabled: true }] : []), ...groupOptions()], (value) => { change({ group: value }); redraw(); }),
196
- choose('Group order', state.view.groupDir, [{ value: 'asc', label: 'Ascending' }, { value: 'desc', label: 'Descending' }], (value) => { change({ groupDir: value }); redraw(); }, { disabled: none }),
197
- list && !none ? bin('Remove the grouping', () => { change({ group: 'none' }); redraw(); }) : h('span', { class: 'fx-rows__gap' }))),
198
- ];
199
- }, { rows: true, clearLabel: 'Remove the grouping' });
200
-
201
- // ── Sort ──
202
- // Rows of sorts, each applied in turn. In the list, a column's head sorts
203
- // too.
204
- const sortOptions = () => [
205
- ...Object.entries(SORTS).map(([value, label]) => ({ value, label })),
206
- { group: 'Custom fields', options: state.fields.map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
207
- ];
208
- const setSorts = (sorts) => change({ sorts });
209
- const sortMenu = menu(ICONS.sort, (redraw) => {
210
- const { sorts } = state.view;
211
- const unused = sortOptions().flatMap((o) => o.options ?? [o]).filter((o) => !sorts.some((s) => s.by === o.value));
212
- return [
213
- title('Sort'),
214
- sorts.length ? h('div', { class: 'fx-rows' }, sorts.map((s, i) => {
215
- const [up, down] = dirWords(s.by);
216
- const others = sorts.filter((_, n) => n !== i).map((x) => x.by);
217
- const options = sortOptions().map((o) => (o.group ? { ...o, options: o.options.filter((x) => !others.includes(x.value)) } : o)).filter((o) => o.group || !others.includes(o.value));
218
- return h('div', { class: 'fx-rows__row fx-rows__row--sort' },
219
- h('span', { class: 'fx-rows__lead' }, i ? 'then' : 'Sort by'),
220
- choose('Sort by', s.by, options, (by) => { setSorts(sorts.map((x, n) => (n === i ? { by, dir: x.dir } : x))); redraw(); }),
221
- choose('Order', s.dir, [{ value: 'asc', label: up }, { value: 'desc', label: down }], (dir) => { setSorts(sorts.map((x, n) => (n === i ? { ...x, dir } : x))); redraw(); }),
222
- bin('Remove this sort', () => { setSorts(sorts.filter((_, n) => n !== i)); redraw(); }));
223
- })) : h('p', { class: 'fx-rows__empty' }, 'No sort. Jobs show in the lab system’s order.'),
224
- rowsFoot(
225
- sorts.length < 5 && unused.length ? addButton('Add sort', () => { setSorts([...sorts, { by: unused[0].value, dir: 'asc' }]); redraw(); }) : h('span'),
226
- sorts.length ? clearAll(() => { setSorts([]); redraw(); }) : null),
227
- ];
228
- }, { rows: true, clearLabel: 'Clear the sort' });
190
+ const isList = () => state.view.layout === 'list';
191
+ const groupMenu = buildGroupMenu({
192
+ options: groupOptions,
193
+ view: () => ({ group: state.view.group === 'none' && !isList() ? 'status' : state.view.group, groupDir: state.view.groupDir }),
194
+ change,
195
+ clearable: isList,
196
+ });
197
+ const sortMenu = buildSortMenu({
198
+ options: () => [
199
+ ...Object.entries(SORTS).map(([value, label]) => ({ value, label })),
200
+ { group: 'Custom fields', options: state.fields.map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
201
+ ],
202
+ view: () => state.view,
203
+ change,
204
+ dirWords,
205
+ nameOf: (by) => SORTS[by] ?? fieldById(fieldIdOf(by))?.name ?? 'Custom field',
206
+ empty: 'No sort. Jobs show in the lab system’s order.',
207
+ });
229
208
 
230
209
  // ── Filters ──
231
210
  // The shared filter panel (view-bar.js), as ClickUp's: groups of rows of
@@ -301,8 +280,6 @@ export async function renderJobs(main, ui) {
301
280
  });
302
281
  controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node, ...columnsMenu.node);
303
282
 
304
- // A sort's name: a built-in key or a custom field.
305
- const sortName = (by) => SORTS[by] ?? fieldById(fieldIdOf(by))?.name ?? 'Custom field';
306
283
  function drawControls() {
307
284
  const { group, sorts, layout } = state.view;
308
285
  const list = layout === 'list';
@@ -314,9 +291,7 @@ export async function renderJobs(main, ui) {
314
291
  groupMenu.set(label, shown !== DEFAULT_VIEW.group, grouped ? () => change({ group: list ? 'none' : 'status' }) : null);
315
292
  // In the list, the column heads sort; the Sort chip shows there only
316
293
  // while a sort is on, so it can be seen and cleared.
317
- const [first] = sorts;
318
- sortMenu.set(sorts.length > 1 ? `Sort · ${sorts.length}` : first ? `Sort: ${sortName(first.by)} ${first.dir === 'desc' ? '↓' : '↑'}` : 'Sort', sorts.length > 0,
319
- sorts.length ? () => change({ sorts: [] }) : null);
294
+ sortMenu.draw();
320
295
  sortMenu.wrap.hidden = list && !sorts.length;
321
296
  filterMenu.draw();
322
297
  columnsMenu.set('Columns', false);
@@ -809,44 +784,6 @@ export async function renderJobs(main, ui) {
809
784
  if (field.type === 'checkbox') return value === true ? 1 : 0;
810
785
  return typeof value === 'string' ? value.toLowerCase() : value;
811
786
  }
812
- // A column head's sort button, as ClickUp's: it shows on hover, or
813
- // always while it sorts. A click sorts one way, again the other way, and
814
- // a third time takes the sort off. A right-click on a sorting head opens
815
- // a small menu: change the direction, or clear the sort.
816
- function sortButton(columnId, label) {
817
- const by = sortOf(columnId);
818
- if (!by) return null;
819
- const { sorts } = state.view;
820
- const at = sorts.findIndex((s) => s.by === by);
821
- const on = at >= 0 ? sorts[at] : null;
822
- const [up, down] = dirWords(by);
823
- const next = () => {
824
- if (!on) change({ sorts: [{ by, dir: 'asc' }] });
825
- else if (on.dir === 'asc') change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: 'desc' } : s)) });
826
- else change({ sorts: sorts.filter((_, n) => n !== at) });
827
- };
828
- const words = on ? `Sorted ${on.dir === 'asc' ? up : down}. Click to ${on.dir === 'asc' ? `sort ${down}` : 'clear the sort'}` : `Sort by ${label}`;
829
- return h('button', {
830
- type: 'button', class: `fx-th-sort${on ? ' fx-th-sort--on' : ''}`, 'aria-label': words, title: words,
831
- onclick: (event) => { event.stopPropagation(); next(); },
832
- oncontextmenu: (event) => {
833
- if (!on) return;
834
- event.preventDefault();
835
- event.stopPropagation();
836
- onRightButton(event, () => sortMenuAt(event, by, label, on, at));
837
- },
838
- }, lucide(h, on ? (on.dir === 'asc' ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'),
839
- on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
840
- }
841
- function sortMenuAt(event, by, label, on, at) {
842
- const { sorts } = state.view;
843
- const flip = () => change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)) });
844
- heads.menu.show([
845
- h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { heads.menu.close(); flip(); } },
846
- h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${label}`), h('span', { class: 'fx-pop__end' }, svg(ICONS.tick))),
847
- heads.menu.item('ban', 'Clear sort', () => change({ sorts: sorts.filter((_, n) => n !== at) })),
848
- ], event);
849
- }
850
787
  // The list's heads (columns.js): drag an edge to resize, drag a head to
851
788
  // move it; a click or right-click opens its menu: sort by it, move it,
852
789
  // edit its custom field, hide it. Client always shows, first. + lists the
@@ -862,19 +799,9 @@ export async function renderJobs(main, ui) {
862
799
  ...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
863
800
  ];
864
801
  },
865
- extra: (column, item) => {
866
- const parts = [];
867
- const sort = sortOf(column.id);
868
- if (sort) {
869
- const [up, down] = dirWords(sort);
870
- parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sorts: [{ by: sort, dir: 'asc' }] })),
871
- item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sorts: [{ by: sort, dir: 'desc' }] })), h('div', { class: 'fx-pop__sep' }));
872
- }
873
- if (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)) {
874
- parts.push(item('settings', 'Edit custom field', () => ui.go('/custom-fields')));
875
- }
876
- return parts;
877
- },
802
+ sorting: { sortOf, sorts: () => state.view.sorts, change, dirWords },
803
+ extra: (column, item) => (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)
804
+ ? [item('settings', 'Edit custom field', () => ui.go('/custom-fields'))] : []),
878
805
  });
879
806
 
880
807
  // The list's columns, in the order the person chose them. One column holds
@@ -896,14 +823,14 @@ export async function renderJobs(main, ui) {
896
823
  const allowed = new Set(builtInColumns());
897
824
  // Client is always the first column: it has no drag, Move or Hide.
898
825
  const client = {
899
- id: 'client', label: 'Client', kind: 'client', fixed: true, sort: () => sortButton('client', 'Client'),
826
+ id: 'client', label: 'Client', kind: 'client', fixed: true,
900
827
  cell: (job) => h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job)),
901
828
  };
902
829
  return [client, ...state.view.columns.map((id) => {
903
830
  if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
904
831
  const field = customColumns().find((f) => fieldGroup(f.id) === id);
905
832
  return field ? fieldColumn(h, field, { id, valueOf: (job) => valueOf(job, field.id)?.value ?? null, people: state.people }) : null;
906
- }).filter(Boolean).map((c) => ({ ...c, sort: () => sortButton(c.id, c.label) }))];
833
+ }).filter(Boolean)];
907
834
  }
908
835
 
909
836
  // A person custom field's value as a badge (initials), the name on hover.
@@ -953,17 +880,13 @@ export async function renderJobs(main, ui) {
953
880
  saveFolded(state.folded);
954
881
  draw();
955
882
  };
956
- const head = ({ label, jobs, rank, color }) => {
957
- const open = !folded.has(label);
958
- const toggle = h('button', {
959
- type: 'button', class: 'fx-table__fold', 'aria-expanded': String(open), 'aria-label': `${label}, ${jobs.length} ${jobs.length === 1 ? 'job' : 'jobs'}. ${open ? 'Fold' : 'Open'} the group`,
960
- onclick: (event) => { event.stopPropagation(); fold(label); },
961
- }, svg(CHEVRON_ICON),
962
- color ? h('span', { class: 'fx-cf__opt fx-board__lane', 'data-color': color }, label, h('span', { class: 'fx-stage__count' }, String(jobs.length))) : stagePill(label, jobs.length, rank));
963
- return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: () => fold(label) },
964
- tickCell(setBox(jobs, `Tick every job in ${label}`)),
965
- h('td', { colspan: String(columns.length + 1) }, h('span', { class: 'fx-table__group-row' }, toggle, h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))))));
966
- };
883
+ const head = ({ label, jobs, rank, color }) => groupHead(h, {
884
+ label: `${label}, ${jobs.length} ${jobs.length === 1 ? 'job' : 'jobs'}`,
885
+ badge: color ? h('span', { class: 'fx-cf__opt fx-board__lane', 'data-color': color }, label, h('span', { class: 'fx-stage__count' }, String(jobs.length))) : stagePill(label, jobs.length, rank),
886
+ open: !folded.has(label), onFold: () => fold(label),
887
+ lead: tickCell(setBox(jobs, `Tick every job in ${label}`)), span: columns.length + 1,
888
+ extra: h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))),
889
+ });
967
890
  return h('div', { class: 'fx-panel' }, heads.table({
968
891
  className: 'fx-table--jobs',
969
892
  columns,