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