@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.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +104 -2
- package/package.json +6 -2
- package/server/app.js +4 -2
- package/server/auth/people.js +2 -2
- package/server/integrations/call-guard.js +21 -4
- package/server/integrations/xero/client.js +12 -2
- package/server/integrations/xero/migrations/0010_xero_call_usage.sql +11 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/routes.js +29 -5
- package/server/integrations/xero/runtime.js +5 -0
- package/server/integrations/xero/usage.js +74 -0
- package/server/ui/app.js +5 -2
- package/server/ui/columns.js +49 -0
- package/server/ui/flow.css +119 -22
- package/server/ui/invoicing.js +33 -10
- package/server/ui/job-view.js +11 -3
- package/server/ui/jobs.js +6 -37
- package/server/ui/kit.js +15 -9
- package/server/ui/page.js +110 -5
- package/server/ui/reconcile.js +1 -1
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +4 -4
- package/server/ui/xero-usage-model.js +106 -0
- package/server/ui/xero-usage.js +133 -0
package/server/ui/flow.css
CHANGED
|
@@ -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:
|
|
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):
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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-
|
|
440
|
-
.fx-views__item
|
|
441
|
-
.fx-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
1435
|
+
@media (max-width: 639px) {
|
|
1409
1436
|
.fx-rule__condition { grid-template-columns: minmax(0, 1fr); }
|
|
1410
1437
|
}
|
|
1411
|
-
@media (max-width:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|
package/server/ui/invoicing.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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) =>
|
|
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:
|
|
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,
|
package/server/ui/job-view.js
CHANGED
|
@@ -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
|
|
486
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
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'}` },
|
|
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 }),
|
|
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
|