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