@7365admin1/layer-common 3.2.2-staging.182 → 3.2.2-staging.188

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.
@@ -336,7 +336,7 @@
336
336
  <v-icon size="13">mdi-close</v-icon>
337
337
  </v-btn>
338
338
  </div>
339
- <v-btn size="x-small" variant="text" color="primary" class="text-none" prepend-icon="mdi-plus" @click="block.items.push({ key: '', label: '' })">
339
+ <v-btn size="x-small" variant="text" color="primary" class="text-none" prepend-icon="mdi-plus" @click="addCheckboxItem(block)">
340
340
  Add checkbox
341
341
  </v-btn>
342
342
  </template>
@@ -351,7 +351,7 @@
351
351
  density="compact"
352
352
  variant="outlined"
353
353
  hide-details
354
- @update:model-value="block.key = labelToKey($event)"
354
+ @update:model-value="onSignatureLabelChange(block, $event)"
355
355
  />
356
356
  </v-col>
357
357
  <v-col cols="12">
@@ -373,6 +373,15 @@
373
373
  </div>
374
374
  </div>
375
375
 
376
+ <!-- At least one fillable block is required — a form with only
377
+ headings/paragraphs/dividers has nothing for a resident to
378
+ actually submit. -->
379
+ <div v-if="blocks.length > 0 && !hasFillableBlock" class="px-4 pb-3">
380
+ <span class="text-caption text-error">
381
+ Add at least one Field, Checkbox Group, or Signature block before saving.
382
+ </span>
383
+ </div>
384
+
376
385
  <!-- Error -->
377
386
  <div v-if="message" class="px-4 pb-3">
378
387
  <span class="text-caption text-error">{{ message }}</span>
@@ -520,6 +529,39 @@ function labelToKey(label: string): string {
520
529
  );
521
530
  }
522
531
 
532
+ /**
533
+ * Every field key currently in use across the form, so a newly-generated key
534
+ * never collides with a sibling's — otherwise two fields left blank (or given
535
+ * the same label) end up bound to the same value in the resident app, and
536
+ * filling one silently fills the other.
537
+ */
538
+ function collectUsedKeys(excludeRef?: any): Set<string> {
539
+ const keys = new Set<string>();
540
+ for (const b of blocks.value) {
541
+ if (b.type === "field" && b.field && b.field !== excludeRef && b.field.key) {
542
+ keys.add(b.field.key);
543
+ }
544
+ if (b.type === "checkbox-group" && Array.isArray(b.items)) {
545
+ for (const item of b.items) {
546
+ if (item !== excludeRef && item?.key) keys.add(item.key);
547
+ }
548
+ }
549
+ if (b.type === "signature-row" && b !== excludeRef && b.key) {
550
+ keys.add(b.key);
551
+ }
552
+ }
553
+ return keys;
554
+ }
555
+
556
+ /** `base` (or "field" when blank) made unique against `used` via a numeric suffix. */
557
+ function uniqueKey(base: string, used: Set<string>): string {
558
+ const root = base || "field";
559
+ if (!used.has(root)) return root;
560
+ let i = 2;
561
+ while (used.has(`${root}${i}`)) i++;
562
+ return `${root}${i}`;
563
+ }
564
+
523
565
  const fieldTypeOptions = [
524
566
  { title: "Text", value: "text" },
525
567
  { title: "Textarea (multi-line)", value: "textarea" },
@@ -603,18 +645,26 @@ function addBlock(type: string) {
603
645
  case "divider":
604
646
  b = { _uid: nextUid(), type: "divider" };
605
647
  break;
606
- case "field":
607
- b = { _uid: nextUid(), type: "field", field: { key: "", label: "", fieldType: "text", required: false } };
648
+ case "field": {
649
+ const uid = nextUid();
650
+ b = { _uid: uid, type: "field", field: { key: uniqueKey(`field${uid}`, collectUsedKeys()), label: "", fieldType: "text", required: false } };
608
651
  break;
609
- case "field-select":
610
- b = { _uid: nextUid(), type: "field", field: { key: "", label: "", fieldType: "select", required: false, options: [] } };
652
+ }
653
+ case "field-select": {
654
+ const uid = nextUid();
655
+ b = { _uid: uid, type: "field", field: { key: uniqueKey(`field${uid}`, collectUsedKeys()), label: "", fieldType: "select", required: false, options: [] } };
611
656
  break;
612
- case "checkbox-group":
613
- b = { _uid: nextUid(), type: "checkbox-group", title: "", single: false, items: [{ key: "", label: "" }] };
657
+ }
658
+ case "checkbox-group": {
659
+ const uid = nextUid();
660
+ b = { _uid: uid, type: "checkbox-group", title: "", single: false, items: [{ key: uniqueKey(`item${uid}`, collectUsedKeys()), label: "" }] };
614
661
  break;
615
- case "signature-row":
616
- b = { _uid: nextUid(), type: "signature-row", label: "", key: "" };
662
+ }
663
+ case "signature-row": {
664
+ const uid = nextUid();
665
+ b = { _uid: uid, type: "signature-row", label: "", key: uniqueKey(`signature${uid}`, collectUsedKeys()) };
617
666
  break;
667
+ }
618
668
  default:
619
669
  return;
620
670
  }
@@ -624,7 +674,9 @@ function addBlock(type: string) {
624
674
 
625
675
  // Auto-generate key from label (field blocks)
626
676
  function onLabelChange(block: AnyBlock, newLabel: string) {
627
- if (!block._keyEdited) block.field.key = labelToKey(newLabel);
677
+ if (!block._keyEdited) {
678
+ block.field.key = uniqueKey(labelToKey(newLabel), collectUsedKeys(block.field));
679
+ }
628
680
  }
629
681
 
630
682
  function onFieldTypeChange(block: AnyBlock, newType: string) {
@@ -633,7 +685,16 @@ function onFieldTypeChange(block: AnyBlock, newType: string) {
633
685
 
634
686
  // Auto-generate key from label (checkbox items)
635
687
  function onCheckboxLabelChange(item: AnyBlock, newLabel: string) {
636
- if (!item._keyEdited) item.key = labelToKey(newLabel);
688
+ if (!item._keyEdited) item.key = uniqueKey(labelToKey(newLabel), collectUsedKeys(item));
689
+ }
690
+
691
+ function addCheckboxItem(block: AnyBlock) {
692
+ block.items.push({ key: uniqueKey(`item${nextUid()}`, collectUsedKeys()), label: "" });
693
+ }
694
+
695
+ // Auto-generate key from label (signature-row)
696
+ function onSignatureLabelChange(block: AnyBlock, newLabel: string) {
697
+ block.key = uniqueKey(labelToKey(newLabel), collectUsedKeys(block));
637
698
  }
638
699
 
639
700
  // ── Template loading ──────────────────────────────────────────────────────────
@@ -650,10 +711,18 @@ function loadTemplate(name: string) {
650
711
  }
651
712
 
652
713
  // ── Computed ──────────────────────────────────────────────────────────────────
714
+ // Only these block types actually collect something from the resident — a
715
+ // form built entirely out of headings/paragraphs/dividers has nothing to
716
+ // submit, so it isn't a usable form yet.
717
+ const FILLABLE_BLOCK_TYPES = ["field", "checkbox-group", "signature-row"];
718
+ const hasFillableBlock = computed(() =>
719
+ blocks.value.some((b) => FILLABLE_BLOCK_TYPES.includes(b.type)),
720
+ );
721
+
653
722
  const canSubmit = computed(() => {
654
723
  const nameOk = prop.mode === "edit" || !!formName.value.trim();
655
724
  const payOk = !requiresPayment.value || paymentAmount.value > 0;
656
- return nameOk && payOk && !loading.value;
725
+ return nameOk && payOk && hasFillableBlock.value && !loading.value;
657
726
  });
658
727
 
659
728
  // ── Payload builder ───────────────────────────────────────────────────────────
@@ -9,9 +9,12 @@
9
9
  <slot name="title-actions" />
10
10
  </v-card-title>
11
11
 
12
- <!-- Submission metadata bar (readonly only) -->
12
+ <!-- Submission metadata bar (readonly only). Shown for an actual
13
+ submission (submissionStatus) OR a pure template preview that
14
+ requires payment — a preview has no submission yet, but should
15
+ still tell the admin the form carries a fee. -->
13
16
  <div
14
- v-if="readonly && (submissionStatus)"
17
+ v-if="readonly && (submissionStatus || requiresPayment)"
15
18
  class="online-form-fill__meta px-6 py-2 d-flex align-center ga-4"
16
19
  >
17
20
  <!-- `StatusChip` IS the design's status pill: it maps the WORD to a tone
@@ -35,9 +38,16 @@
35
38
  </span>
36
39
  <template v-if="requiresPayment">
37
40
  <!-- The payment pair is not a status WORD the shared map knows, so the
38
- tone is given directly - the same pill, the same measured pair. -->
41
+ tone is given directly - the same pill, the same measured pair.
42
+ A preview has no submission to be paid/unpaid yet, so it shows
43
+ the configured fee instead of a status that doesn't apply. -->
39
44
  <StatusChip
40
- v-if="initialManagementValues?.paymentStatus"
45
+ v-if="!submissionStatus"
46
+ tone="info"
47
+ :label="`Requires Payment: $${paymentAmount}`"
48
+ />
49
+ <StatusChip
50
+ v-else-if="initialManagementValues?.paymentStatus"
41
51
  tone="ok"
42
52
  :label="`Payment ${initialManagementValues.paymentStatus}`"
43
53
  />
@@ -35,7 +35,9 @@
35
35
  </span>
36
36
  <v-text-field
37
37
  v-model="search"
38
- @keydown.enter="emit('search')"
38
+ @keydown.enter="triggerSearch"
39
+ clearable
40
+ @click:clear="clearSearch"
39
41
  density="compact"
40
42
  width="100%"
41
43
  hide-details
@@ -178,6 +180,33 @@ watchEffect(() => {
178
180
  }
179
181
  });
180
182
 
183
+ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
184
+ let skipNextWatch = false;
185
+
186
+ const triggerSearch = () => {
187
+ if (searchDebounce) clearTimeout(searchDebounce);
188
+ emit("search");
189
+ };
190
+
191
+ const clearSearch = () => {
192
+ skipNextWatch = true;
193
+ search.value = "";
194
+ triggerSearch();
195
+ };
196
+
197
+ watch(search, () => {
198
+ if (skipNextWatch) {
199
+ skipNextWatch = false;
200
+ return;
201
+ }
202
+ if (searchDebounce) clearTimeout(searchDebounce);
203
+ searchDebounce = setTimeout(triggerSearch, 1000);
204
+ });
205
+
206
+ onBeforeUnmount(() => {
207
+ if (searchDebounce) clearTimeout(searchDebounce);
208
+ });
209
+
181
210
  const selectItem = (value: string) => {
182
211
  selected.value = value;
183
212
  menu.value = false;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "3.2.2-staging.182",
5
+ "version": "3.2.2-staging.188",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -0,0 +1,108 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { moduleNameFromRoute } from "./breadcrumb.ts";
5
+
6
+ /**
7
+ * Real routes, not invented ones: name, path and parameters exactly as
8
+ * vue-router hands them to the bar in each application.
9
+ */
10
+ const ID = "68b0c1d2e3f4a5b6c7d8e9f0";
11
+
12
+ test("a site application names the page, not its parameters", () => {
13
+ assert.equal(
14
+ moduleNameFromRoute({
15
+ name: "org-site-work-orders",
16
+ path: `/${ID}/${ID}/work-orders`,
17
+ params: { org: ID, site: ID },
18
+ }),
19
+ "Work Orders"
20
+ );
21
+ });
22
+
23
+ test("a detail route names its module, not its argument", () => {
24
+ assert.equal(
25
+ moduleNameFromRoute({
26
+ name: "org-site-feedbacks-id",
27
+ path: `/${ID}/${ID}/feedbacks/${ID}`,
28
+ params: { org: ID, site: ID, id: ID },
29
+ }),
30
+ "Feedbacks"
31
+ );
32
+ assert.equal(
33
+ moduleNameFromRoute({
34
+ name: "org-site-members-status",
35
+ path: `/${ID}/${ID}/members/pending`,
36
+ params: { org: ID, site: ID, status: "pending" },
37
+ }),
38
+ "Members"
39
+ );
40
+ });
41
+
42
+ test("a fixed folder in front of the context parameter is not the page", () => {
43
+ // THE DEFECT: every screen of the organization application read "Org".
44
+ assert.equal(
45
+ moduleNameFromRoute({
46
+ name: "org-organization-marketplace",
47
+ path: `/org/${ID}/marketplace`,
48
+ params: { organization: ID },
49
+ }),
50
+ "Marketplace"
51
+ );
52
+ assert.equal(
53
+ moduleNameFromRoute({
54
+ name: "org-organization-payment-methods-linked",
55
+ path: `/org/${ID}/payment-methods/linked`,
56
+ params: { organization: ID },
57
+ }),
58
+ "Payment Methods Linked"
59
+ );
60
+ });
61
+
62
+ test("a literal second segment is NOT a skipped context", () => {
63
+ // `/organizations/status/:status` also begins with a literal folder, but
64
+ // "Organizations" is the page here - only the THIRD segment is a parameter.
65
+ assert.equal(
66
+ moduleNameFromRoute({
67
+ name: "organizations-status-status",
68
+ path: "/organizations/status/pending",
69
+ params: { status: "pending" },
70
+ }),
71
+ "Organizations"
72
+ );
73
+ });
74
+
75
+ test("a route with no parameters is left alone", () => {
76
+ assert.equal(
77
+ moduleNameFromRoute({
78
+ name: "super-admin-client-list",
79
+ path: "/super-admin/client-list",
80
+ params: {},
81
+ }),
82
+ "Super Admin Client List"
83
+ );
84
+ assert.equal(
85
+ moduleNameFromRoute({ name: "personal-info-name", path: "/personal-info/name", params: {} }),
86
+ "Personal Info Name"
87
+ );
88
+ });
89
+
90
+ test("acronyms keep their case", () => {
91
+ assert.equal(
92
+ moduleNameFromRoute({
93
+ name: "org-site-virtual-patrol-cctv",
94
+ path: `/${ID}/${ID}/virtual-patrol/cctv`,
95
+ params: { org: ID, site: ID },
96
+ }),
97
+ "Virtual Patrol CCTV"
98
+ );
99
+ });
100
+
101
+ test("a context-only route has no page half", () => {
102
+ // The organization dashboard: the bar shows the organization's name alone.
103
+ assert.equal(
104
+ moduleNameFromRoute({ name: "org-organization", path: `/org/${ID}`, params: { organization: ID } }),
105
+ ""
106
+ );
107
+ assert.equal(moduleNameFromRoute({ name: "", path: "/", params: {} }), "");
108
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * THE TOP BAR'S BREADCRUMB, PAGE HALF.
3
+ *
4
+ * The bar draws `Context / Page`. The page half is derived from the route the
5
+ * router is already on - nothing is fetched and nothing is configured per app,
6
+ * because the applications build their route names from their own folder
7
+ * structure: `org-site-work-orders` is Work Orders.
8
+ *
9
+ * Lives here rather than in the component so the rule is testable: it is the
10
+ * one piece of the bar with real branching, and it named a whole application's
11
+ * screens wrongly for want of a case.
12
+ */
13
+ const ACRONYMS: Record<string, string> = {
14
+ cctv: "CCTV",
15
+ hid: "HID",
16
+ dob: "DOB",
17
+ anpr: "ANPR",
18
+ nfc: "NFC",
19
+ qr: "QR",
20
+ soa: "SOA",
21
+ sp: "SP",
22
+ id: "ID",
23
+ };
24
+
25
+ export function moduleNameFromRoute(route: {
26
+ name?: unknown;
27
+ path?: unknown;
28
+ params?: Record<string, unknown> | null;
29
+ }): string {
30
+ const segments = String(route.name ?? "").split("-").filter(Boolean);
31
+ const params = new Set(Object.keys(route.params ?? {}));
32
+
33
+ let i = 0;
34
+
35
+ /**
36
+ * A FIXED FOLDER IN FRONT OF THE CONTEXT PARAMETER IS NOT THE PAGE.
37
+ *
38
+ * The site applications are `/:org/:site/...`, so skipping the leading
39
+ * parameters was enough. The organization application is
40
+ * `/org/:organization/...` - a literal folder first - and the route NAME
41
+ * cannot tell that "org" is a folder and not a parameter, so every one of
42
+ * that application's screens named itself "Org" instead of the page it is.
43
+ *
44
+ * The PATH can tell: its first segment is that same word, and the segment
45
+ * after it is a parameter's VALUE. Checked against the path rather than
46
+ * assumed, because `/organizations/status/:status` also begins with a
47
+ * literal - and there "Organizations Status" is the right page name, its
48
+ * second path segment being the folder `status` and not the parameter's
49
+ * value.
50
+ */
51
+ const values = new Set(
52
+ Object.values(route.params ?? {})
53
+ .flat()
54
+ .map((v) => String(v))
55
+ .filter(Boolean)
56
+ );
57
+ const pathParts = String(route.path ?? "").split("/").filter(Boolean);
58
+
59
+ if (
60
+ segments.length > 1 &&
61
+ !params.has(segments[0]) &&
62
+ pathParts[0] === segments[0] &&
63
+ values.has(String(pathParts[1] ?? ""))
64
+ ) {
65
+ i++;
66
+ }
67
+
68
+ /**
69
+ * Leading parameter segments (`org`, `site`) are skipped and the name stops
70
+ * at the first one that follows, so a detail route (`...-feedbacks-id`) and
71
+ * a status route (`...-members-status`) both name their module rather than
72
+ * their argument.
73
+ */
74
+ while (i < segments.length && params.has(segments[i])) i++;
75
+
76
+ const words: string[] = [];
77
+ while (i < segments.length && !params.has(segments[i])) words.push(segments[i++]);
78
+
79
+ return words
80
+ .map((w) => ACRONYMS[w] ?? w.charAt(0).toUpperCase() + w.slice(1))
81
+ .join(" ");
82
+ }
@@ -0,0 +1,118 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { buildWorkOrderStatus } from "./dashboard.ts";
5
+
6
+ /**
7
+ * THE DEFECT THIS FILE EXISTS FOR.
8
+ *
9
+ * The Property Management "Work Order Status" panel read
10
+ * `workOrderStatusSummary` - a key no endpoint returns - and fell through to
11
+ * the `openWorkOrder` KPI metric, which carries no `completed` field. So
12
+ * Completed read 0 on every site on every day, and Pending was silently
13
+ * recomputed as (open work orders - in progress) instead of read.
14
+ *
15
+ * The shape below is the one `new-dashboard.repo.ts` actually sends.
16
+ */
17
+ test("the server's breakdown is read, not recomputed", () => {
18
+ const rows = buildWorkOrderStatus({
19
+ pending: 20,
20
+ inProgress: 12,
21
+ completed: 55,
22
+ });
23
+
24
+ assert.deepEqual(
25
+ rows.map((r) => [r.label, r.value]),
26
+ [
27
+ ["Completed", 55],
28
+ ["In Progress", 12],
29
+ ["Pending", 20],
30
+ ]
31
+ );
32
+ });
33
+
34
+ /**
35
+ * The exact regression. Before the fix this same input drew
36
+ * Completed 0 / In Progress 12 / Pending 28.
37
+ */
38
+ test("Completed is never zero when the server says it is not", () => {
39
+ const completed = buildWorkOrderStatus({
40
+ pending: 20,
41
+ inProgress: 12,
42
+ completed: 55,
43
+ }).find((r) => r.label === "Completed");
44
+
45
+ assert.equal(completed?.value, 55);
46
+ });
47
+
48
+ /**
49
+ * The bars are share-of-largest-bucket, so the biggest is always full width
50
+ * and the rest are proportional to it. This is the widths the panel draws.
51
+ */
52
+ test("each bar is its share of the largest bucket", () => {
53
+ const rows = buildWorkOrderStatus({ pending: 25, inProgress: 50, completed: 100 });
54
+
55
+ assert.deepEqual(
56
+ rows.map((r) => r.percent),
57
+ [100, 50, 25]
58
+ );
59
+ });
60
+
61
+ /** Three empty buckets must draw three empty bars, not divide by zero. */
62
+ test("an all-zero breakdown does not divide by zero", () => {
63
+ const rows = buildWorkOrderStatus({ pending: 0, inProgress: 0, completed: 0 });
64
+
65
+ assert.equal(rows.length, 3);
66
+ for (const row of rows) {
67
+ assert.equal(row.value, 0);
68
+ assert.equal(row.percent, 0, row.label);
69
+ assert.ok(Number.isFinite(row.percent), row.label);
70
+ }
71
+ });
72
+
73
+ /** A partial payload is read for what it has and zeroed for what it does not. */
74
+ test("missing buckets read as zero rather than undefined", () => {
75
+ const rows = buildWorkOrderStatus({ completed: 7 });
76
+
77
+ assert.deepEqual(
78
+ rows.map((r) => r.value),
79
+ [7, 0, 0]
80
+ );
81
+ });
82
+
83
+ /**
84
+ * NO FALLBACK, DELIBERATELY. When the breakdown is absent there is nothing
85
+ * honest to draw, so the panel must show its empty state - not bars built out
86
+ * of a different metric. An empty array is what triggers that.
87
+ */
88
+ test("no breakdown means no bars, never invented ones", () => {
89
+ assert.deepEqual(buildWorkOrderStatus(undefined), []);
90
+ assert.deepEqual(buildWorkOrderStatus(null), []);
91
+ assert.deepEqual(buildWorkOrderStatus(42 as never), []);
92
+ assert.deepEqual(buildWorkOrderStatus("55" as never), []);
93
+ });
94
+
95
+ /**
96
+ * The tone is a token NAME, not a colour. The template writes `var(--<tone>)`,
97
+ * so a tone that is not a real token silently paints nothing.
98
+ */
99
+ test("every row carries a real design token name", () => {
100
+ const rows = buildWorkOrderStatus({ pending: 1, inProgress: 2, completed: 3 });
101
+
102
+ assert.deepEqual(
103
+ rows.map((r) => r.tone),
104
+ ["ok", "err", "warn"]
105
+ );
106
+ });
107
+
108
+ /**
109
+ * The buckets are the whole of the server's status report - it folds every
110
+ * unrecognised status into `pending` rather than dropping it - so nothing the
111
+ * server counted goes missing on the way to the screen.
112
+ */
113
+ test("the three buckets account for the whole breakdown", () => {
114
+ const src = { pending: 20, inProgress: 12, completed: 55 };
115
+ const total = buildWorkOrderStatus(src).reduce((sum, r) => sum + r.value, 0);
116
+
117
+ assert.equal(total, src.pending + src.inProgress + src.completed);
118
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Dashboard arithmetic that is worth testing on its own.
3
+ *
4
+ * `DashboardMain.vue` is 4,800 lines of template and wiring, and none of it is
5
+ * reachable from `node --test`. The one piece with real arithmetic in it - the
6
+ * work-order status breakdown - lives here instead, so the defect that made
7
+ * "Completed" read zero on every Property Management dashboard has a test that
8
+ * fails if it ever comes back.
9
+ */
10
+
11
+ export type TWorkOrderStatusSource = {
12
+ pending?: number;
13
+ inProgress?: number;
14
+ completed?: number;
15
+ } | null | undefined;
16
+
17
+ export type TWorkOrderStatusRow = {
18
+ label: string;
19
+ value: number;
20
+ /** Share of the largest bucket, 0-100 - the bar's width. */
21
+ percent: number;
22
+ /** A design token name, not a colour: the caller writes `var(--<tone>)`. */
23
+ tone: "ok" | "err" | "warn";
24
+ };
25
+
26
+ /**
27
+ * The three status bars, from the breakdown the server sends as
28
+ * `workOrderStatus` (new-dashboard.repo.ts, property branch).
29
+ *
30
+ * NO FALLBACK. The screen used to read `workOrderStatusSummary` - a key no
31
+ * endpoint returns - and fall through to the `openWorkOrder` KPI metric, which
32
+ * carries no `completed` field: Completed was therefore ALWAYS 0 and Pending
33
+ * was silently recomputed as (open work orders - in progress) instead of read.
34
+ * Without the real breakdown there is nothing honest to draw, so this returns
35
+ * an empty list and the panel shows its empty state.
36
+ */
37
+ export function buildWorkOrderStatus(
38
+ src: TWorkOrderStatusSource
39
+ ): TWorkOrderStatusRow[] {
40
+ if (!src || typeof src !== "object") return [];
41
+
42
+ const completed = Number(src.completed ?? 0);
43
+ const inProgress = Number(src.inProgress ?? 0);
44
+ const pending = Number(src.pending ?? 0);
45
+ // `1` floors the divisor: three empty buckets must not divide by zero.
46
+ const max = Math.max(completed, inProgress, pending, 1);
47
+
48
+ return [
49
+ { label: "Completed", value: completed, percent: (completed / max) * 100, tone: "ok" },
50
+ { label: "In Progress", value: inProgress, percent: (inProgress / max) * 100, tone: "err" },
51
+ { label: "Pending", value: pending, percent: (pending / max) * 100, tone: "warn" },
52
+ ];
53
+ }
@@ -76,9 +76,15 @@ export const LIGHT_PAIRS: Array<{
76
76
  bg: string;
77
77
  need: number;
78
78
  }> = [
79
- { what: "muted on a card (table header text, sub-lines)", fg: L.muted, bg: L.card, need: 4.5 },
79
+ { what: "muted on a card (KPI sub-lines, field labels)", fg: L.muted, bg: L.card, need: 4.5 },
80
80
  { what: "muted on the page background", fg: L.muted, bg: L.bg, need: 4.5 },
81
- { what: "muted on a table header band", fg: L.muted, bg: thead, need: 4.5 },
81
+ /*
82
+ * `muted on a table header band` used to sit here at 3.10:1. `.table-card
83
+ * thead th` now takes `--text2` (9.22:1 light / 9.57:1 dark), and nothing
84
+ * else paints `--muted` on a `--thead` band - `.table-card__group`'s label
85
+ * is Vuetify's `text-medium-emphasis`. The pair no longer occurs, so it is
86
+ * no longer carried as accepted debt.
87
+ */
82
88
  { what: "muted on the sidebar", fg: L.muted, bg: L.sidebar, need: 4.5 },
83
89
  { what: "muted on a disabled control's hover fill", fg: L.muted, bg: hoverOnBg, need: 4.5 },
84
90
  { what: "ok label on its own chip", fg: L.ok, bg: chip.ok, need: 4.5 },
@@ -115,7 +121,7 @@ export const DARK_PAIRS = () => {
115
121
  return [
116
122
  { what: "muted on a card", fg: D.muted, bg: D.card, need: 4.5 },
117
123
  { what: "muted on the page background", fg: D.muted, bg: D.bg, need: 4.5 },
118
- { what: "muted on a table header band", fg: D.muted, bg: dthead, need: 4.5 },
124
+ { what: "muted on a --thead band (the tint itself, no longer the header text)", fg: D.muted, bg: dthead, need: 4.5 },
119
125
  { what: "muted on the sidebar", fg: D.muted, bg: D.sidebar, need: 4.5 },
120
126
  { what: "text on a card", fg: D.text, bg: D.card, need: 4.5 },
121
127
  { what: "text2 on a card", fg: D.text2, bg: D.card, need: 4.5 },