@7365admin1/layer-common 3.2.2-staging.183 → 3.2.2-staging.189
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/CHANGELOG.md +6 -0
- package/assets/css/primitives.css +22 -0
- package/assets/css/screens.css +10 -1
- package/components/DashboardEmptyState.vue +37 -2
- package/components/DashboardMain.vue +289 -115
- package/components/Layout/Header.vue +7 -32
- package/components/SwitchContext.vue +30 -1
- package/package.json +1 -1
- package/utils/breadcrumb.test.ts +108 -0
- package/utils/breadcrumb.ts +82 -0
- package/utils/dashboard.test.ts +118 -0
- package/utils/dashboard.ts +53 -0
- package/utils/theme-aa-ledger.ts +9 -3
- package/utils/theme.test.ts +88 -4
|
@@ -89,6 +89,8 @@
|
|
|
89
89
|
</template>
|
|
90
90
|
|
|
91
91
|
<script setup lang="ts">
|
|
92
|
+
import { moduleNameFromRoute } from "../../utils/breadcrumb";
|
|
93
|
+
|
|
92
94
|
const props = defineProps({
|
|
93
95
|
hideNavIcon: {
|
|
94
96
|
type: Boolean,
|
|
@@ -142,39 +144,12 @@ const siteName = computed(() =>
|
|
|
142
144
|
const route = useRoute();
|
|
143
145
|
|
|
144
146
|
/**
|
|
145
|
-
* The
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* status route (`...-members-status`) both name their module rather than their
|
|
150
|
-
* argument.
|
|
147
|
+
* The page's name comes off the route name, which the applications build from
|
|
148
|
+
* their own folder structure: `org-site-work-orders` is Work Orders. The rule
|
|
149
|
+
* itself lives in `utils/breadcrumb.ts`, where it is unit-tested against every
|
|
150
|
+
* route shape the eleven applications actually have.
|
|
151
151
|
*/
|
|
152
|
-
const
|
|
153
|
-
cctv: "CCTV",
|
|
154
|
-
hid: "HID",
|
|
155
|
-
dob: "DOB",
|
|
156
|
-
anpr: "ANPR",
|
|
157
|
-
nfc: "NFC",
|
|
158
|
-
qr: "QR",
|
|
159
|
-
soa: "SOA",
|
|
160
|
-
sp: "SP",
|
|
161
|
-
id: "ID",
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const moduleName = computed(() => {
|
|
165
|
-
const segments = String(route.name ?? "").split("-").filter(Boolean);
|
|
166
|
-
const params = new Set(Object.keys(route.params ?? {}));
|
|
167
|
-
|
|
168
|
-
let i = 0;
|
|
169
|
-
while (i < segments.length && params.has(segments[i])) i++;
|
|
170
|
-
|
|
171
|
-
const words: string[] = [];
|
|
172
|
-
while (i < segments.length && !params.has(segments[i])) words.push(segments[i++]);
|
|
173
|
-
|
|
174
|
-
return words
|
|
175
|
-
.map((w) => ACRONYMS[w] ?? w.charAt(0).toUpperCase() + w.slice(1))
|
|
176
|
-
.join(" ");
|
|
177
|
-
});
|
|
152
|
+
const moduleName = computed(() => moduleNameFromRoute(route));
|
|
178
153
|
|
|
179
154
|
const profile = computed(() => {
|
|
180
155
|
return `/api/public/${currentUser.value?.profile}`;
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
</span>
|
|
36
36
|
<v-text-field
|
|
37
37
|
v-model="search"
|
|
38
|
-
@keydown.enter="
|
|
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.
|
|
5
|
+
"version": "3.2.2-staging.189",
|
|
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
|
+
}
|
package/utils/theme-aa-ledger.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
|
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 },
|
package/utils/theme.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { test } from "node:test";
|
|
3
4
|
|
|
4
5
|
import { DARK_THEME, LIGHT_THEME, PALETTE, type TPalette } from "./theme.ts";
|
|
@@ -505,9 +506,8 @@ test("the light palette is the handoff's, value for value", () => {
|
|
|
505
506
|
*/
|
|
506
507
|
test("the light theme's sub-AA pairs measure exactly what the ledger says", () => {
|
|
507
508
|
const EXPECTED: Record<string, number> = {
|
|
508
|
-
"muted on a card (
|
|
509
|
+
"muted on a card (KPI sub-lines, field labels)": 3.24,
|
|
509
510
|
"muted on the page background": 2.99,
|
|
510
|
-
"muted on a table header band": 3.1,
|
|
511
511
|
"muted on the sidebar": 3.13,
|
|
512
512
|
"muted on a disabled control's hover fill": 2.71,
|
|
513
513
|
"ok label on its own chip": 4.57,
|
|
@@ -548,8 +548,8 @@ test("the light theme's sub-AA pairs measure exactly what the ledger says", () =
|
|
|
548
548
|
const failing = measured.filter((r) => !r.passes).length;
|
|
549
549
|
assert.equal(
|
|
550
550
|
failing,
|
|
551
|
-
|
|
552
|
-
`${failing} light pairs are below AA; the recorded, accepted count is
|
|
551
|
+
7,
|
|
552
|
+
`${failing} light pairs are below AA; the recorded, accepted count is 7. ` +
|
|
553
553
|
"If this moved, the owner's design-fidelity decision needs re-stating."
|
|
554
554
|
);
|
|
555
555
|
});
|
|
@@ -560,6 +560,90 @@ test("the dark theme has no sub-AA pairs at all", () => {
|
|
|
560
560
|
assert.deepEqual(bad, [], "dark mode was exact AND compliant - keep it that way");
|
|
561
561
|
});
|
|
562
562
|
|
|
563
|
+
/* ------------------------------------------------------------------ */
|
|
564
|
+
/* The two contrast fixes QA drove, asserted at the token level AND at */
|
|
565
|
+
/* the stylesheet that wires them. */
|
|
566
|
+
/* ------------------------------------------------------------------ */
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* A native `<select>`'s option list is painted by the browser. With no
|
|
570
|
+
* background of its own the option keeps the theme's ink over whatever the
|
|
571
|
+
* browser paints - white on Windows/Chrome - which is 1.14:1 in dark mode.
|
|
572
|
+
* The rule gives it `--card`/`--text`, the same pair the themed popover uses.
|
|
573
|
+
*/
|
|
574
|
+
test("a native option's own fill carries the theme ink in both themes", () => {
|
|
575
|
+
for (const [name, p] of [["light", PALETTE.light], ["dark", PALETTE.dark]] as const) {
|
|
576
|
+
assert.ok(
|
|
577
|
+
contrast(p.text, p.card) >= 4.5,
|
|
578
|
+
`${name}: option ink on the card fill is ${contrast(p.text, p.card).toFixed(2)}:1`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// What the defect measured, so the number stays on the record.
|
|
583
|
+
assert.equal(Number(contrast(PALETTE.dark.text, "#ffffff").toFixed(2)), 1.14);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The table header. `--muted` measured 3.10:1 on the header band in light -
|
|
588
|
+
* below AA and reported by two apps' QA. `--text2` clears AA in both themes
|
|
589
|
+
* while staying clearly below the primary ink the data rows use, so the
|
|
590
|
+
* header still reads as secondary to the data.
|
|
591
|
+
*/
|
|
592
|
+
test("the table header clears AA and still reads below the data", () => {
|
|
593
|
+
for (const [name, p, wash] of [
|
|
594
|
+
["light", PALETTE.light, "#14161a"],
|
|
595
|
+
["dark", PALETTE.dark, "#ffffff"],
|
|
596
|
+
] as const) {
|
|
597
|
+
const band = over(wash, p.card, 0.02);
|
|
598
|
+
const header = contrast(p.text2, band);
|
|
599
|
+
const data = contrast(p.text, band);
|
|
600
|
+
|
|
601
|
+
assert.ok(header >= 4.5, `${name}: header is ${header.toFixed(2)}:1, below AA`);
|
|
602
|
+
assert.ok(
|
|
603
|
+
header < data,
|
|
604
|
+
`${name}: the header (${header.toFixed(2)}:1) must stay below the data ` +
|
|
605
|
+
`(${data.toFixed(2)}:1) or it stops reading as secondary`
|
|
606
|
+
);
|
|
607
|
+
assert.ok(
|
|
608
|
+
contrast(p.muted, band) < header,
|
|
609
|
+
`${name}: --text2 must be an improvement on the --muted it replaced`
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The ratios above are the tokens'. They say nothing about whether the CSS
|
|
616
|
+
* still ASKS for them - a deleted rule would leave every number here passing.
|
|
617
|
+
* These two assertions are the wiring. Neither can measure a real computed
|
|
618
|
+
* style: that needs a browser, and the measured evidence for it is in the PR.
|
|
619
|
+
*/
|
|
620
|
+
test("the stylesheets still ask for the tokens the two fixes chose", () => {
|
|
621
|
+
const css = (f: string) =>
|
|
622
|
+
readFileSync(new URL(`../assets/css/${f}`, import.meta.url), "utf8")
|
|
623
|
+
.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
624
|
+
|
|
625
|
+
const primitives = css("primitives.css");
|
|
626
|
+
const optionRule = primitives.slice(primitives.indexOf("select option {"));
|
|
627
|
+
assert.ok(
|
|
628
|
+
primitives.includes("select option {"),
|
|
629
|
+
"the native <option> fill rule is gone - dark mode goes back to 1.14:1"
|
|
630
|
+
);
|
|
631
|
+
const optionBody = optionRule.slice(0, optionRule.indexOf("}"));
|
|
632
|
+
assert.ok(
|
|
633
|
+
optionBody.includes("background-color: var(--card)"),
|
|
634
|
+
"the option lost its own fill, which is the whole defect"
|
|
635
|
+
);
|
|
636
|
+
assert.ok(optionBody.includes("color: var(--text)"), "the option lost its ink");
|
|
637
|
+
|
|
638
|
+
const screens = css("screens.css");
|
|
639
|
+
const header = screens.slice(screens.indexOf(".table-card thead th {"));
|
|
640
|
+
const headerBody = header.slice(0, header.indexOf("}"));
|
|
641
|
+
assert.ok(
|
|
642
|
+
headerBody.includes("color: var(--text2) !important"),
|
|
643
|
+
"the table header is back on a token that does not clear AA in light"
|
|
644
|
+
);
|
|
645
|
+
});
|
|
646
|
+
|
|
563
647
|
/**
|
|
564
648
|
* THE BRIDGE. Every design token in `assets/css/tokens.css` is an alias onto a
|
|
565
649
|
* `--v-theme-*`, so a token whose Vuetify colour does not exist resolves to
|