@arcforgelabs/flow 2026.10.42 → 2026.10.43
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/integrations/sentry/index.js +9 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +74 -29
- package/server/ui/automations.js +9 -3
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +77 -37
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoicing-settings.js +69 -35
- package/server/ui/invoicing.js +16 -50
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/jobs.js +88 -32
- package/server/ui/monitoring.js +1 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- package/server/ui/routes.js +1 -1
- package/server/ui/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/xero-import.js +7 -10
package/server/ui/flow.css
CHANGED
|
@@ -170,15 +170,30 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
170
170
|
.fx-main { flex: 1; min-width: 0; max-width: 1180px; margin: 0 auto; padding: 32px clamp(16px, 4vw, 48px) 64px; display: grid; gap: 20px; align-content: start; }
|
|
171
171
|
.fx-main:focus { outline: none; box-shadow: none; }
|
|
172
172
|
|
|
173
|
-
/* Page header: title
|
|
174
|
-
.
|
|
175
|
-
.fx-
|
|
173
|
+
/* Page header: the title only (no line under it), then the actions,
|
|
174
|
+
primary last. One height on every page, with or without a button. */
|
|
175
|
+
.fx-ph { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; min-height: 36px; }
|
|
176
|
+
.fx-ph__text { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
176
177
|
.fx-title {
|
|
177
178
|
color: var(--t-strong);
|
|
178
|
-
font: var(--t-display-weight)
|
|
179
|
+
font: var(--t-display-weight) 24px/1.15 var(--t-display); letter-spacing: -0.015em;
|
|
179
180
|
}
|
|
181
|
+
.fx-ph__count { color: var(--t-dim); font-size: 14px; font-variant-numeric: tabular-nums; }
|
|
182
|
+
/* A record's facts beside its title (Reconcile: client · date · total). */
|
|
180
183
|
.fx-ph__sub { color: var(--t-dim); }
|
|
181
|
-
|
|
184
|
+
/* A docs link as a "?" icon beside a title (helpLink in app.js). */
|
|
185
|
+
.fx-help { display: inline-flex; vertical-align: middle; margin-left: 2px; padding: 4px; border-radius: var(--t-radius); color: var(--t-dim); }
|
|
186
|
+
.fx-help:hover, .fx-help:focus-visible { background: var(--fx-hover); color: var(--t-strong); }
|
|
187
|
+
.fx-help .fx-lucide { width: 16px; height: 16px; }
|
|
188
|
+
.fx-ph__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
189
|
+
|
|
190
|
+
/* The tip an icon-only control shows on hover and keyboard focus (tip.js). */
|
|
191
|
+
.fx-tip {
|
|
192
|
+
position: fixed; top: 0; left: 0; z-index: 1000; max-width: 260px; padding: 4px 8px;
|
|
193
|
+
border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong);
|
|
194
|
+
font: 500 12px/1.3 var(--t-body); pointer-events: none; white-space: nowrap;
|
|
195
|
+
box-shadow: var(--t-shadow), 0 4px 12px rgba(0, 0, 0, .12);
|
|
196
|
+
}
|
|
182
197
|
|
|
183
198
|
/* ── Notices, pills, tags, toasts ──────────────────────────────────────── */
|
|
184
199
|
.fx-notice {
|
|
@@ -287,10 +302,12 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
287
302
|
.fx-chip { border: 1px solid var(--fx-line); border-radius: 999px; background: var(--t-paper); color: var(--t-text); }
|
|
288
303
|
.fx-int-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
|
|
289
304
|
.fx-int-bar .fx-search { margin: 0; }
|
|
305
|
+
.fx-int-bar .fx-row { flex: 1 1 auto; flex-wrap: nowrap; justify-content: flex-end; min-width: 0; }
|
|
306
|
+
.fx-int-bar .fx-row .fx-search { flex: 0 1 320px; width: auto; min-width: 0; }
|
|
307
|
+
.fx-int-bar .fx-row .fx-select { flex: none; }
|
|
290
308
|
.fx-int-sections { display: grid; gap: 32px; }
|
|
291
309
|
.fx-int-section-list { display: grid; gap: 16px; }
|
|
292
310
|
.fx-int-heading { color: var(--t-strong); font: 600 19px/1.3 var(--t-body); }
|
|
293
|
-
.fx-int-heading-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
294
311
|
.fx-int-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
|
295
312
|
.fx-int-card {
|
|
296
313
|
display: grid; grid-template-rows: auto 1fr auto; gap: 14px; padding: 18px;
|
|
@@ -298,6 +315,7 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
298
315
|
color: var(--t-text); text-decoration: none;
|
|
299
316
|
transition: transform 140ms var(--fx-ease), box-shadow 140ms var(--fx-ease), border-color 140ms var(--fx-ease);
|
|
300
317
|
}
|
|
318
|
+
.fx-int-card--bare { grid-template-rows: auto auto; }
|
|
301
319
|
.fx-int-card:hover, .fx-int-card:focus-visible { transform: translateY(-2px); border-color: var(--t-brd-hi, var(--t-control)); box-shadow: 0 8px 24px rgba(0, 0, 0, .1); }
|
|
302
320
|
.fx-int-card__head { display: flex; align-items: center; gap: 12px; }
|
|
303
321
|
.fx-int-card__head h3 { flex: 1; color: var(--t-strong); font: 650 16px/1.25 var(--t-body); }
|
|
@@ -312,6 +330,7 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
312
330
|
display: grid; place-items: center; flex: none; width: 44px; height: 44px; border-radius: 10px;
|
|
313
331
|
background: #fff; box-shadow: inset 0 0 0 1px var(--fx-line);
|
|
314
332
|
}
|
|
333
|
+
.fx-logo--icon { background: var(--t-accent-bg); color: var(--t-accent); box-shadow: none; }
|
|
315
334
|
.fx-logo img { width: 28px; height: 28px; object-fit: contain; }
|
|
316
335
|
.fx-logo--lg { width: 52px; height: 52px; }
|
|
317
336
|
.fx-logo--lg img { width: 34px; height: 34px; }
|
|
@@ -473,7 +492,8 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
473
492
|
}
|
|
474
493
|
|
|
475
494
|
/* ── Jobs board ─────────────────────────────────────────────────────────── */
|
|
476
|
-
.fx-main
|
|
495
|
+
/* Every page shares .fx-main's width; only the board's lanes use the whole window. */
|
|
496
|
+
.fx-main--board { max-width: none; }
|
|
477
497
|
.fx-main > * { min-width: 0; }
|
|
478
498
|
.fx-toolbar--plain { border-bottom: 0; align-items: center; }
|
|
479
499
|
.fx-toolbar--plain .fx-search { margin-bottom: 0; }
|
|
@@ -674,7 +694,14 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
674
694
|
the activity at the side. It stacks on a phone. */
|
|
675
695
|
.fx-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--t-dim); overflow-wrap: anywhere; }
|
|
676
696
|
.fx-dialog.fx-task { width: min(1040px, calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow-y: auto; }
|
|
677
|
-
.fx-task__grid { display: grid; grid-template-columns: minmax(0, 1fr)
|
|
697
|
+
.fx-task__grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; }
|
|
698
|
+
/* No sidecar: the main area takes the room. Review is wider for its table. */
|
|
699
|
+
.fx-task__grid:has(> .fx-sidecar[hidden]) { grid-template-columns: minmax(0, 1fr); }
|
|
700
|
+
.fx-task__grid:has(> .fx-sidecar [data-tab="review"][aria-selected="true"]) { grid-template-columns: minmax(0, 1fr) 400px; }
|
|
701
|
+
.fx-task__side { background: var(--t-s1); }
|
|
702
|
+
.fx-task__tools { display: flex; align-items: center; gap: 4px; }
|
|
703
|
+
.fx-task__tools .fx-lucide { width: 16px; height: 16px; }
|
|
704
|
+
.fx-task__tools [aria-pressed="true"] { background: var(--fx-hover); color: var(--t-strong); }
|
|
678
705
|
.fx-task__main { min-width: 0; padding: 20px 28px 28px; }
|
|
679
706
|
.fx-task__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
680
707
|
.fx-task__crumb { display: flex; flex-wrap: wrap; gap: 6px; color: var(--t-dim); font-size: 12.5px; }
|
|
@@ -689,9 +716,14 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
689
716
|
.fx-task__link { color: var(--t-accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--t-accent) 40%, transparent); }
|
|
690
717
|
.fx-task__link:hover { border-bottom-color: currentColor; }
|
|
691
718
|
.fx-task__fix { scroll-margin-top: 16px; margin-top: 22px; border: 1px solid var(--t-warn-brd, var(--t-warn)); border-radius: var(--t-radius); background: var(--t-warn-bg); }
|
|
692
|
-
.fx-task__fix h3, .fx-task__lines h3
|
|
719
|
+
.fx-task__fix h3, .fx-task__lines h3 { margin: 0; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
693
720
|
.fx-task__fix h3 { padding: 12px 14px 4px; color: var(--t-warn); }
|
|
694
|
-
.fx-task__fixrow { padding: 9px 14px; border-top: 1px solid var(--fx-line-soft); color: var(--t-strong); font-size: 13.5px; }
|
|
721
|
+
.fx-task__fixrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 9px 14px; border-top: 1px solid var(--fx-line-soft); color: var(--t-strong); font-size: 13.5px; }
|
|
722
|
+
.fx-task__fixrow > strong { font-weight: 600; }
|
|
723
|
+
.fx-task__fixrow > span { flex: 1 1 160px; color: var(--t-text); }
|
|
724
|
+
.fx-task__linkbtn { all: unset; cursor: pointer; color: var(--t-accent); border-bottom: 1px solid color-mix(in srgb, var(--t-accent) 40%, transparent); }
|
|
725
|
+
.fx-task__linkbtn:hover { border-bottom-color: currentColor; }
|
|
726
|
+
.fx-task__linkbtn:focus-visible { box-shadow: var(--fx-focus); }
|
|
695
727
|
.fx-task__fixrow:first-of-type { border-top: 0; }
|
|
696
728
|
.fx-task__choose { scroll-margin-top: 16px; margin-top: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 14px; border: 1px solid var(--t-warn-brd, var(--t-warn)); border-radius: var(--t-radius); background: var(--t-warn-bg); color: var(--t-strong); font-size: 13.5px; }
|
|
697
729
|
.fx-task__choose > svg { flex: none; width: 20px; height: 20px; color: var(--t-warn); }
|
|
@@ -758,8 +790,9 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
758
790
|
.fx-task__miss { color: var(--t-warn); }
|
|
759
791
|
.fx-task__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; }
|
|
760
792
|
.fx-task__actions:empty { display: none; }
|
|
761
|
-
.fx-task__activity { min-width: 0;
|
|
762
|
-
|
|
793
|
+
.fx-task__activity { min-width: 0; }
|
|
794
|
+
/* Review in the sidecar: the change banner, without its own box. */
|
|
795
|
+
.fx-task__review .fx-change { margin: 0; padding: 0; border: 0; background: none; }
|
|
763
796
|
.fx-task__activity ol { margin: 0; padding: 0; list-style: none; }
|
|
764
797
|
.fx-task__activity li { position: relative; padding: 0 0 16px 18px; color: var(--t-text); font-size: 13px; }
|
|
765
798
|
.fx-task__activity li::before { content: ""; position: absolute; left: 3px; top: 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--t-mute); }
|
|
@@ -771,7 +804,8 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
771
804
|
.fx-task__grid { grid-template-columns: minmax(0, 1fr); }
|
|
772
805
|
.fx-task__main { padding: 16px; }
|
|
773
806
|
.fx-task__fields { grid-template-columns: minmax(0, 1fr); }
|
|
774
|
-
.fx-
|
|
807
|
+
.fx-task__grid:has(> .fx-sidecar [data-tab="review"][aria-selected="true"]) { grid-template-columns: minmax(0, 1fr); }
|
|
808
|
+
.fx-task__side { border-top: 1px solid var(--fx-line); border-left: 0; }
|
|
775
809
|
}
|
|
776
810
|
|
|
777
811
|
/* ── Dialog ─────────────────────────────────────────────────────────────── */
|
|
@@ -898,8 +932,13 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
898
932
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .15);
|
|
899
933
|
}
|
|
900
934
|
.fx-swatch[aria-checked="true"] { box-shadow: 0 0 0 2px var(--t-paper), 0 0 0 4px var(--t-text); }
|
|
901
|
-
|
|
902
|
-
.fx-swatch--default
|
|
935
|
+
/* The default accent: a pill that says Default, before the colours. */
|
|
936
|
+
.fx-swatch--default {
|
|
937
|
+
display: inline-flex; align-items: center; gap: 6px; width: auto; padding: 0 10px 0 4px; border-radius: 999px;
|
|
938
|
+
background: var(--t-paper); color: var(--t-text); font: 600 12.5px/1 var(--t-body); box-shadow: inset 0 0 0 1px var(--t-control);
|
|
939
|
+
}
|
|
940
|
+
.fx-swatch--default[aria-checked="true"] { box-shadow: inset 0 0 0 1px var(--t-control), 0 0 0 2px var(--t-paper), 0 0 0 4px var(--t-text); }
|
|
941
|
+
.fx-swatch__dot { flex: none; width: 22px; height: 22px; border-radius: 50%; }
|
|
903
942
|
.fx-swatch-custom { display: inline-flex; align-items: center; gap: 8px; color: var(--t-dim); font-size: 13px; font-weight: 600; }
|
|
904
943
|
.fx-swatch-custom input { width: 34px; height: 30px; padding: 0; border: 1px solid var(--t-control); border-radius: var(--t-radius); background: var(--t-paper); cursor: pointer; }
|
|
905
944
|
|
|
@@ -946,7 +985,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
946
985
|
/* ── Phones ─────────────────────────────────────────────────────────────── */
|
|
947
986
|
@media (max-width: 640px) {
|
|
948
987
|
.fx-main { padding-top: 20px; }
|
|
949
|
-
.fx-title { font-size:
|
|
988
|
+
.fx-title { font-size: 22px; }
|
|
950
989
|
.fx-table thead { display: none; }
|
|
951
990
|
.fx-table, .fx-table tbody, .fx-table tr, .fx-table td { display: block; width: 100%; }
|
|
952
991
|
.fx-table tr { padding: 12px 14px; border-bottom: 1px solid var(--fx-line-soft); }
|
|
@@ -955,7 +994,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
955
994
|
.fx-table tbody tr:hover td { background: none; }
|
|
956
995
|
.fx-table__actions { text-align: left; }
|
|
957
996
|
.fx-table__actions .fx-row--end { justify-content: flex-start; }
|
|
958
|
-
.fx-ph__actions, .fx-ph__actions .fx-btn { width: 100%; }
|
|
959
997
|
/* People: the name with its actions (⋯, Approve) on one line, then the
|
|
960
998
|
role select full width, then the status pill when there is one. */
|
|
961
999
|
.fx-people tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px 10px; }
|
|
@@ -1118,10 +1156,22 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1118
1156
|
in from the right of the manager and leaves the table usable. */
|
|
1119
1157
|
.fx-cfm__card { position: relative; }
|
|
1120
1158
|
.fx-cfm__card:has(> .fx-cfs:not([hidden])) { min-height: min(640px, 100vh); }
|
|
1121
|
-
|
|
1122
|
-
.
|
|
1123
|
-
|
|
1124
|
-
|
|
1159
|
+
/* The shared sidecar (sidecar.js): a right column, or over the page with
|
|
1160
|
+
--over. Tabs are an icon and one word; × closes it. */
|
|
1161
|
+
.fx-sidecar { display: flex; flex-direction: column; min-width: 0; border-left: 1px solid var(--fx-line); background: var(--t-paper); color: var(--t-text); }
|
|
1162
|
+
.fx-sidecar[hidden] { display: none; }
|
|
1163
|
+
.fx-sidecar--over { position: absolute; inset: 0 0 0 auto; z-index: 5; width: min(420px, 100%); box-shadow: -16px 0 40px rgba(0, 0, 0, .22); animation: fx-sidecar-in .18s var(--fx-ease); }
|
|
1164
|
+
@keyframes fx-sidecar-in { from { transform: translateX(24px); opacity: 0; } }
|
|
1165
|
+
@media (prefers-reduced-motion: reduce) { .fx-sidecar--over { animation: none; } }
|
|
1166
|
+
.fx-sidecar__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 52px; padding: 0 12px 0 8px; border-bottom: 1px solid var(--fx-line-soft); }
|
|
1167
|
+
.fx-sidecar__tabs { display: flex; gap: 2px; min-width: 0; }
|
|
1168
|
+
.fx-sidecar__tab { all: unset; cursor: pointer; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 10px; border-radius: var(--t-radius); color: var(--t-dim); font-size: 13px; font-weight: 500; }
|
|
1169
|
+
.fx-sidecar__tab .fx-lucide { width: 15px; height: 15px; }
|
|
1170
|
+
.fx-sidecar__tab:hover { background: var(--fx-hover); color: var(--t-strong); }
|
|
1171
|
+
.fx-sidecar__tab[aria-selected="true"] { background: var(--fx-hover); color: var(--t-strong); }
|
|
1172
|
+
.fx-sidecar__tab:focus-visible { box-shadow: var(--fx-focus); }
|
|
1173
|
+
.fx-sidecar__mark { width: 6px; height: 6px; border-radius: 50%; background: var(--t-warn); }
|
|
1174
|
+
.fx-sidecar__body { flex: 1; min-height: 0; overflow-y: auto; padding: 16px; }
|
|
1125
1175
|
.fx-cfs__form { position: sticky; top: 0; display: flex; flex-direction: column; width: 100%; height: min(100%, 100vh); max-height: 100vh; min-height: 0; }
|
|
1126
1176
|
.fx-cfs__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 52px; padding: 0 12px 0 16px; border-bottom: 1px solid var(--fx-line-soft); }
|
|
1127
1177
|
.fx-cfs__typename, .fx-cfs__typepick { display: inline-flex; align-items: center; gap: 8px; color: var(--t-text); font-weight: 500; font-size: 14px; }
|
|
@@ -1129,9 +1179,9 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1129
1179
|
.fx-cfs__typepick:hover, .fx-cfs__typepick:focus-within { background: var(--fx-hover); }
|
|
1130
1180
|
.fx-cfs__typepick .fx-lucide { width: 15px; height: 15px; }
|
|
1131
1181
|
.fx-cfs__type { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
|
1132
|
-
.fx-
|
|
1133
|
-
.fx-
|
|
1134
|
-
.fx-
|
|
1182
|
+
.fx-sidecar__close, .fx-cfs__delete, .fx-cfs__tool { all: unset; cursor: pointer; display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 6px; color: var(--t-dim); }
|
|
1183
|
+
.fx-sidecar__close:hover, .fx-cfs__tool:hover:not(:disabled) { background: var(--fx-hover); color: var(--t-strong); }
|
|
1184
|
+
.fx-sidecar__close:focus-visible, .fx-cfs__delete:focus-visible, .fx-cfs__tool:focus-visible { box-shadow: var(--fx-focus); }
|
|
1135
1185
|
.fx-cfs__body { flex: 1; min-height: 0; overflow-y: auto; display: grid; align-content: start; gap: 0; }
|
|
1136
1186
|
.fx-cfs__section { display: grid; gap: 8px; padding: 14px 16px 4px; }
|
|
1137
1187
|
.fx-cfs__section:last-child { margin-top: 14px; padding-top: 16px; border-top: 1px solid var(--fx-line-soft); }
|
|
@@ -1250,13 +1300,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1250
1300
|
.fx-pager__step:hover:not(:disabled) { background: var(--fx-hover); border-color: var(--fx-line); }
|
|
1251
1301
|
.fx-pager__step:disabled { opacity: 0.35; cursor: default; }
|
|
1252
1302
|
|
|
1253
|
-
/* New invoice from a job: a
|
|
1254
|
-
list of jobs with no invoice. */
|
|
1255
|
-
.fx-fromjob {
|
|
1256
|
-
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px;
|
|
1257
|
-
padding: 12px 16px; border: 1px dashed var(--fx-line); border-radius: var(--t-radius);
|
|
1258
|
-
}
|
|
1259
|
-
.fx-fromjob__text { display: grid; gap: 2px; font-size: 14px; }
|
|
1303
|
+
/* New invoice from a job: a searchable list of jobs with no invoice. */
|
|
1260
1304
|
.fx-dialog:has(.fx-pick__body) { width: min(620px, calc(100vw - 32px)); }
|
|
1261
1305
|
.fx-pick__body { display: grid; gap: 12px; }
|
|
1262
1306
|
.fx-pick { display: grid; max-height: min(420px, 55vh); overflow-y: auto; border: 1px solid var(--fx-line); border-radius: var(--t-radius); }
|
|
@@ -1300,12 +1344,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1300
1344
|
.fx-bulk__act[data-tone="danger"] { color: var(--t-danger); }
|
|
1301
1345
|
.fx-bulk__act[data-tone="danger"]:hover { background: var(--t-danger-bg); }
|
|
1302
1346
|
.fx-bulk__sep { flex: none; width: 1px; height: 18px; margin: 0 4px; background: var(--fx-line); }
|
|
1303
|
-
/* An icon-only action says its name on hover, in a tip above it. */
|
|
1304
|
-
.fx-bulk [data-tip]:hover::after, .fx-bulk [data-tip]:focus-visible::after {
|
|
1305
|
-
content: attr(data-tip); position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
|
|
1306
|
-
padding: 4px 8px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong);
|
|
1307
|
-
font: 500 12px/1.2 var(--t-body); pointer-events: none; box-shadow: var(--t-shadow), 0 4px 12px rgba(0, 0, 0, .12);
|
|
1308
|
-
}
|
|
1309
1347
|
/* More: a compact list, one row per action, like a dropdown. */
|
|
1310
1348
|
.fx-pop.fx-bulk__more { width: 248px; min-width: 0; padding: 4px; }
|
|
1311
1349
|
.fx-bulk__search { width: 100%; height: 30px; margin-bottom: 2px; padding: 4px 8px; font-size: 13px; }
|
|
@@ -1370,6 +1408,8 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1370
1408
|
|
|
1371
1409
|
/* A list's own search (view-bar.js findBox): an icon that opens into a box. */
|
|
1372
1410
|
.fx-find { display: inline-flex; align-items: center; }
|
|
1411
|
+
/* On a tab bar the icon sits level with the tab names, not on the line. */
|
|
1412
|
+
.fx-toolbar > .fx-find { align-self: center; }
|
|
1373
1413
|
.fx-find__button { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; }
|
|
1374
1414
|
.fx-find__button .fx-lucide { width: 15px; height: 15px; }
|
|
1375
1415
|
.fx-find__input { display: none; width: min(240px, 60vw); height: 32px; padding: 0 10px; }
|
|
@@ -18,9 +18,11 @@ export function sendModeNote(to, sendMode, what = 'each invoice') {
|
|
|
18
18
|
return text.join(' ');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
// A warning above a draft, only when its Send cannot work: the accounting
|
|
22
|
+
// system is off or takes no invoices. Otherwise nothing: the problems show
|
|
23
|
+
// on their own, and the Send dialog says what Send does.
|
|
24
|
+
export function draftNote(to, invoice) {
|
|
25
|
+
const sendMode = invoice?.sendMode;
|
|
26
|
+
if (!to || !sendMode || (sendMode.connected && sendMode.writesAllowed)) return null;
|
|
27
|
+
return sendModeNote(to, sendMode);
|
|
26
28
|
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
// Settings,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// change saves at once. Everyone can read them; an owner
|
|
9
|
-
// them. The full rules are in docs/INVOICING_RULES.md.
|
|
10
|
-
// shared pieces as `ui`.
|
|
1
|
+
// Settings, Rules (/invoicing-settings): a card for each part, like the
|
|
2
|
+
// Integrations page, and one page for each (/invoicing-settings/<key>).
|
|
3
|
+
// Drafts holds payment terms and the Reference; Price codes the lines with no
|
|
4
|
+
// price item (line-mappings.js); Invoices the checks a ready job passes
|
|
5
|
+
// before Flow drafts its invoice (invoice-rules.js, docs/INVOICE_RULES.md).
|
|
6
|
+
// Automations have their own page (automations.js).
|
|
7
|
+
// How an invoice goes to Xero is on the Xero integration page
|
|
8
|
+
// (sendModeSection). A change saves at once. Everyone can read them; an owner
|
|
9
|
+
// or admin changes them. The full rules are in docs/INVOICING_RULES.md.
|
|
10
|
+
// app.js passes in its shared pieces as `ui`.
|
|
11
11
|
|
|
12
|
-
import { automationSection } from '/assets/ui/automations.js';
|
|
13
12
|
import { rulesSection } from '/assets/ui/invoice-rules.js';
|
|
14
13
|
import { lineMappingSection } from '/assets/ui/line-mappings.js';
|
|
15
14
|
|
|
@@ -20,19 +19,51 @@ const SEND_MODES = [
|
|
|
20
19
|
{ value: 'approve_and_email', label: 'Approved and emailed' },
|
|
21
20
|
];
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
const onCount = (list) => ({ label: `${list.filter((x) => x.enabled).length} on`, tone: list.some((x) => x.enabled) ? 'success' : 'mute' });
|
|
23
|
+
|
|
24
|
+
// Each card: its page, its mark, and its state once the data is in. No
|
|
25
|
+
// blurb: the name and the state say enough.
|
|
26
|
+
const PARTS = [
|
|
27
|
+
{ key: 'drafts', title: 'Drafts', mark: 'draft', mount: (ui) => draftsSection(ui) },
|
|
28
|
+
{ key: 'price-codes', title: 'Price codes', mark: 'tag', mount: (ui) => lineMappingSection(ui),
|
|
29
|
+
summary: async (flow) => {
|
|
30
|
+
const { labels } = await flow('/api/invoicing/line-labels');
|
|
31
|
+
const waiting = labels.filter((l) => l.unmapped > 0).length;
|
|
32
|
+
return waiting ? { status: { label: `${waiting} wait`, tone: 'warn' }, action: 'Fix' } : { status: { label: 'All mapped', tone: 'success' } };
|
|
33
|
+
} },
|
|
34
|
+
{ key: 'invoices', title: 'Invoices', mark: 'rules', mount: (ui) => rulesSection(ui, 'Invoices'),
|
|
35
|
+
summary: async (flow) => ({ status: onCount((await flow('/api/invoicing/rules')).rules) }) },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function renderInvoicingSettings(main, ui, id) {
|
|
39
|
+
const { h, flow, notice, pageHeader, tileCard, crumbs } = ui;
|
|
40
|
+
if (id) {
|
|
41
|
+
const part = PARTS.find((p) => p.key === id);
|
|
42
|
+
main.append(crumbs(['/invoicing-settings', 'Rules'], part?.title ?? 'Not found'),
|
|
43
|
+
part ? h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, part.mount(ui)) : notice('No such part of Rules.', 'danger'));
|
|
32
44
|
return;
|
|
33
45
|
}
|
|
46
|
+
const grid = h('div', { class: 'fx-int-grid' });
|
|
47
|
+
main.append(pageHeader('Rules'), grid);
|
|
48
|
+
// Each card shows at once and fills in its state when its data comes back.
|
|
49
|
+
for (const part of PARTS) {
|
|
50
|
+
const href = `/invoicing-settings/${part.key}`;
|
|
51
|
+
const slot = tileCard({ href, mark: part.mark, title: part.title });
|
|
52
|
+
grid.append(slot);
|
|
53
|
+
if (part.summary) {
|
|
54
|
+
part.summary(flow)
|
|
55
|
+
.then((more) => slot.replaceWith(tileCard({ href, mark: part.mark, title: part.title, ...more })))
|
|
56
|
+
.catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Payment terms and the Reference template.
|
|
62
|
+
function draftsSection(ui) {
|
|
63
|
+
const { h, flow, toast, notice, me } = ui;
|
|
34
64
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
35
|
-
|
|
65
|
+
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Drafts'), h('div', { class: 'fx-skeleton' }));
|
|
66
|
+
let saved;
|
|
36
67
|
|
|
37
68
|
async function save(changes, control) {
|
|
38
69
|
control.disabled = true;
|
|
@@ -60,19 +91,23 @@ export async function renderInvoicingSettings(main, ui) {
|
|
|
60
91
|
});
|
|
61
92
|
return input;
|
|
62
93
|
};
|
|
63
|
-
const days = (key, label) => box(key, label, { type: 'number', read: Number });
|
|
64
94
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
95
|
+
(async () => {
|
|
96
|
+
try {
|
|
97
|
+
saved = (await flow('/api/invoicing/settings')).settings;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
section.replaceChildren(h('h2', { class: 'fx-set__title' }, 'Drafts'), notice(error.message, 'danger'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
section.replaceChildren(...[
|
|
103
|
+
h('h2', { class: 'fx-set__title' }, 'Drafts'),
|
|
104
|
+
disabled ? notice('Only an owner or admin can change these.', 'info') : null,
|
|
105
|
+
row('Payment terms', 'Days from invoice to due date.', box('paymentTermsDays', 'Payment terms in days', { type: 'number', read: Number })),
|
|
70
106
|
row('Reference', 'The Reference on each invoice. Use {practitioner} and {patient}.',
|
|
71
|
-
box('referenceTemplate', 'Reference rule', { placeholder: '{practitioner} - {patient}' }))
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
].filter(Boolean));
|
|
107
|
+
box('referenceTemplate', 'Reference rule', { placeholder: '{practitioner} - {patient}' })),
|
|
108
|
+
].filter(Boolean));
|
|
109
|
+
})();
|
|
110
|
+
return section;
|
|
76
111
|
}
|
|
77
112
|
|
|
78
113
|
// The Xero integration page's "Sending" section: how a checked invoice goes to
|
|
@@ -82,11 +117,10 @@ export function sendModeSection(ui) {
|
|
|
82
117
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
83
118
|
const select = h('select', { class: 'fx-select', 'aria-label': 'Send to Xero as', disabled },
|
|
84
119
|
SEND_MODES.map((m) => h('option', { value: m.value }, m.label)));
|
|
85
|
-
const section = h('section', { class: 'fx-set' },
|
|
120
|
+
const section = h('section', { class: 'fx-set' }, ui.sectionTitle('Sending', { href: DOCS, label: 'How sending works' }),
|
|
86
121
|
h('div', { class: 'fx-set__row' },
|
|
87
122
|
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, 'Send to Xero as'), h('span', { class: 'fx-set__hint' }, 'Draft: approve in Xero. Approved: Xero emails it.')),
|
|
88
|
-
h('div', { class: 'fx-set__control' }, select))
|
|
89
|
-
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How sending works')));
|
|
123
|
+
h('div', { class: 'fx-set__control' }, select)));
|
|
90
124
|
let saved = null;
|
|
91
125
|
(async () => {
|
|
92
126
|
try {
|
package/server/ui/invoicing.js
CHANGED
|
@@ -9,7 +9,7 @@ import { alertStrip } from '/assets/ui/alerts.js';
|
|
|
9
9
|
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
|
-
import {
|
|
12
|
+
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
13
13
|
import { findBox } from '/assets/ui/view-bar.js';
|
|
14
14
|
|
|
15
15
|
// The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
|
|
@@ -836,15 +836,12 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
836
836
|
dueDate.addEventListener('change', syncIssue);
|
|
837
837
|
syncIssue();
|
|
838
838
|
|
|
839
|
-
// A new invoice can start from a job that has no invoice yet: a
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
const fromJob = isNew && context.canIssue
|
|
844
|
-
h('
|
|
845
|
-
h('span', { class: 'fx-strong' }, 'Billing a job?'),
|
|
846
|
-
h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
|
|
847
|
-
h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
|
|
839
|
+
// A new invoice can start from a job that has no invoice yet: a button in
|
|
840
|
+
// the header. Flow drafts it the way the invoicing rules do (client,
|
|
841
|
+
// Reference, lines) and links it to the job. An invoice with no job is
|
|
842
|
+
// fine too.
|
|
843
|
+
const fromJob = isNew && context.canIssue
|
|
844
|
+
? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => pickJob(ui) }, 'From a job') : null;
|
|
848
845
|
|
|
849
846
|
// A draft with no problem is in Ready by itself; one with a problem is
|
|
850
847
|
// Pending. A changed job is fixed at its source or accepted here.
|
|
@@ -853,47 +850,18 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
853
850
|
// changed. Other problems stay one line.
|
|
854
851
|
const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
|
|
855
852
|
const problems = [
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
// Accepting changes the draft for the business: owners and admins only.
|
|
859
|
-
onAccept: !context.canIssue ? null : async () => {
|
|
860
|
-
const system = jobChange.systemName;
|
|
861
|
-
const lost = [jobChange.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, changed() ? 'Unsaved changes are lost.' : null].filter(Boolean);
|
|
862
|
-
if (lost.length) {
|
|
863
|
-
const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
|
|
864
|
-
if (!ok) return;
|
|
865
|
-
}
|
|
866
|
-
try {
|
|
867
|
-
const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
|
|
868
|
-
toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
|
|
869
|
-
redraw(res.invoice);
|
|
870
|
-
} catch (error) {
|
|
871
|
-
toast(error.message, 'danger');
|
|
872
|
-
}
|
|
873
|
-
},
|
|
874
|
-
// Reads this one job from the work system again, then every check runs.
|
|
875
|
-
onRecheck: async () => {
|
|
876
|
-
try {
|
|
877
|
-
const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/recheck`, { method: 'POST', body: '{}' });
|
|
878
|
-
toast(res.invoice?.jobChange ? `Still changed in ${jobChange.systemName}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
|
|
879
|
-
if (res.invoice) redraw(res.invoice);
|
|
880
|
-
} catch (error) {
|
|
881
|
-
toast(error.message, 'danger');
|
|
882
|
-
}
|
|
883
|
-
},
|
|
884
|
-
}) : null,
|
|
853
|
+
// Accepting changes the draft for the business: owners and admins only.
|
|
854
|
+
jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
|
|
885
855
|
otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
|
|
886
856
|
];
|
|
887
857
|
|
|
888
858
|
return [
|
|
889
859
|
navBar(ui, invoice),
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton)),
|
|
860
|
+
ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
|
|
861
|
+
actions: [fromJob, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton].filter(Boolean),
|
|
862
|
+
}),
|
|
863
|
+
draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
|
|
895
864
|
...problems,
|
|
896
|
-
fromJob,
|
|
897
865
|
h('div', { class: 'fx-invoice__grid' },
|
|
898
866
|
h('div', { class: 'fx-invoice__main' },
|
|
899
867
|
h('section', { class: 'fx-card fx-invoice__head' },
|
|
@@ -999,10 +967,8 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
999
967
|
navBar(ui, invoice),
|
|
1000
968
|
h('header', { class: 'fx-ph' },
|
|
1001
969
|
h('div', { class: 'fx-ph__text' },
|
|
1002
|
-
h('
|
|
1003
|
-
|
|
1004
|
-
h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
|
|
1005
|
-
h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · ${showDate(invoice.issueDate)}`)),
|
|
970
|
+
h('h1', { class: 'fx-title' }, invoice.number),
|
|
971
|
+
h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
|
|
1006
972
|
h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
|
|
1007
973
|
h('div', { class: 'fx-invoice__grid' },
|
|
1008
974
|
h('div', { class: 'fx-invoice__main' },
|
|
@@ -1072,7 +1038,7 @@ function xeroCard(invoice, ui, context, status, reload) {
|
|
|
1072
1038
|
}
|
|
1073
1039
|
|
|
1074
1040
|
// Send to Xero (POST /api/xero/invoices/push). The customer's send mode
|
|
1075
|
-
// (Settings,
|
|
1041
|
+
// (Settings, Rules) decides what Xero gets: a draft that a person approves
|
|
1076
1042
|
// there, or an approved invoice that Xero emails to the customer.
|
|
1077
1043
|
function sendToXero(invoice, ui, context) {
|
|
1078
1044
|
const { h, flow, toast } = ui;
|
package/server/ui/job-board.js
CHANGED
|
@@ -93,7 +93,10 @@ function reasonFix(reason) {
|
|
|
93
93
|
if (reason === 'the work system report is out of date') {
|
|
94
94
|
return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.', go: 'integrations' };
|
|
95
95
|
}
|
|
96
|
-
|
|
96
|
+
// Review opens the job's own sidecar: what changed, and Accept change.
|
|
97
|
+
if (reason === 'the job changed after its invoice was made') {
|
|
98
|
+
return { label: 'Job changed', text: 'Changed after the draft was made.', todo: 'Fix it in the work system, or accept the change.', go: 'review' };
|
|
99
|
+
}
|
|
97
100
|
if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.', 'work');
|
|
98
101
|
if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.', 'work');
|
|
99
102
|
// The matcher found invoices that may be this job's, but no exact link.
|
|
@@ -134,27 +137,36 @@ function draftFix(problem) {
|
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
// Where a fix's link goes: Flow's invoice, the job in the work system, a
|
|
137
|
-
// Flow settings page,
|
|
138
|
-
// (jobs.js draws them).
|
|
139
|
-
export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'choose'];
|
|
140
|
+
// Flow settings page, `review`: the job's Review sidecar, or `choose`: Make
|
|
141
|
+
// invoice and Link invoice buttons (jobs.js draws them).
|
|
142
|
+
export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'review', 'choose'];
|
|
140
143
|
|
|
141
144
|
// What a person must fix on a job, in order: [{ label, text, todo, go }];
|
|
142
145
|
// `todo` is null when the buttons are the instruction.
|
|
143
|
-
// Empty unless invoiceStage is Problem.
|
|
146
|
+
// Empty unless invoiceStage is Problem. One fact is one fix: a job in review
|
|
147
|
+
// already says why, so its draft's copy of that hold (sending.js,
|
|
148
|
+
// `job_review`) is left out, and two fixes with the same words show once.
|
|
144
149
|
export function fixes(o) {
|
|
145
150
|
if (invoiceStage(o) !== 'problem') return [];
|
|
146
151
|
const found = [];
|
|
147
152
|
if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
|
|
148
153
|
if (o.view === 'ready') {
|
|
149
154
|
found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
|
|
150
|
-
label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings,
|
|
155
|
+
label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Rules.', go: 'invoicing-settings',
|
|
151
156
|
})));
|
|
152
157
|
}
|
|
153
158
|
if (o.invoiceStatus === 'void') {
|
|
154
159
|
found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
|
|
155
160
|
}
|
|
156
|
-
|
|
157
|
-
|
|
161
|
+
const held = o.view === 'review_required' && o.holdState === 'review';
|
|
162
|
+
found.push(...(o.invoiceProblems ?? []).filter((p) => !(held && p.code === 'job_review')).map((p) => draftFix(p)));
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
return found.filter((f) => {
|
|
165
|
+
const key = `${f.label}\u0000${f.text}`;
|
|
166
|
+
if (seen.has(key)) return false;
|
|
167
|
+
seen.add(key);
|
|
168
|
+
return true;
|
|
169
|
+
});
|
|
158
170
|
}
|
|
159
171
|
|
|
160
172
|
// The short words for a job's problems: the first label, and "+N" for more.
|
package/server/ui/job-change.js
CHANGED
|
@@ -87,3 +87,39 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
|
|
|
87
87
|
body)) : null,
|
|
88
88
|
more);
|
|
89
89
|
}
|
|
90
|
+
|
|
91
|
+
// The banner with Accept change and the recheck wired, for the invoice page
|
|
92
|
+
// and a job's Review sidecar (jobs.js). `canAccept`: the person may change
|
|
93
|
+
// drafts (owners and admins). `unsaved()`: edits on the page that Accept
|
|
94
|
+
// would lose. `done(invoice)` hears the invoice after Accept or a recheck.
|
|
95
|
+
export function jobChangeReview(ui, change, { money, canAccept = false, unsaved = () => false, done = () => {} }) {
|
|
96
|
+
const { h, flow, toast } = ui;
|
|
97
|
+
const system = change.systemName;
|
|
98
|
+
return jobChangeBanner(ui, change, {
|
|
99
|
+
money,
|
|
100
|
+
onAccept: !canAccept ? null : async () => {
|
|
101
|
+
const lost = [change.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, unsaved() ? 'Unsaved changes are lost.' : null].filter(Boolean);
|
|
102
|
+
if (lost.length) {
|
|
103
|
+
const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
|
|
104
|
+
if (!ok) return;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const res = await flow(`/api/invoicing/jobs/${change.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
|
|
108
|
+
toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
|
|
109
|
+
await done(res.invoice);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
toast(error.message, 'danger');
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
// Reads this one job from the work system again, then every check runs.
|
|
115
|
+
onRecheck: async () => {
|
|
116
|
+
try {
|
|
117
|
+
const res = await flow(`/api/invoicing/jobs/${change.jobId}/recheck`, { method: 'POST', body: '{}' });
|
|
118
|
+
toast(res.invoice?.jobChange ? `Still changed in ${system}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
|
|
119
|
+
if (res.invoice) await done(res.invoice);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
toast(error.message, 'danger');
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|