@7365admin1/layer-common 3.2.2-staging.188 → 3.2.2-staging.190
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/components/DashboardMain.vue +42 -16
- package/components/Nfc/PatrolReport/DailyReportTab.vue +8 -1
- package/components/Nfc/PatrolReport/MonthlyReportTab.vue +8 -1
- package/components/Nfc/PatrolReport/MonthlyReportTable.vue +4 -2
- package/components/Nfc/PatrolReport/PatrolReportTab.vue +8 -0
- package/composables/useNFCPatrolDailyReport.ts +13 -0
- package/composables/useNFCPatrolMonthlyReport.ts +22 -56
- package/composables/useNFCPatrolReport.ts +13 -0
- package/package.json +1 -1
- package/types/nfc-patrol-report.ts +3 -2
- package/utils/nfc-patrol-report.test.ts +130 -0
- package/utils/nfc-patrol-report.ts +101 -0
|
@@ -2060,7 +2060,7 @@ const customizeWidgets = computed(() => {
|
|
|
2060
2060
|
key: "visitors",
|
|
2061
2061
|
title: "Visitors",
|
|
2062
2062
|
description:
|
|
2063
|
-
"See today's visitor traffic and
|
|
2063
|
+
"See today's visitor traffic, and how many passes are still not checked out.",
|
|
2064
2064
|
},
|
|
2065
2065
|
{
|
|
2066
2066
|
key: "facilityBookings",
|
|
@@ -2117,7 +2117,7 @@ const customizeWidgets = computed(() => {
|
|
|
2117
2117
|
key: "visitors",
|
|
2118
2118
|
title: "Visitors",
|
|
2119
2119
|
description:
|
|
2120
|
-
"See today's visitor traffic and
|
|
2120
|
+
"See today's visitor traffic, and how many passes are still not checked out.",
|
|
2121
2121
|
},
|
|
2122
2122
|
{
|
|
2123
2123
|
key: "patrolCompliance",
|
|
@@ -2288,7 +2288,7 @@ function getPropertyKpiCards() {
|
|
|
2288
2288
|
color: KPI_TONES.info.color,
|
|
2289
2289
|
tint: KPI_TONES.info.tint,
|
|
2290
2290
|
value: formatNumber(toNumber(workOrderMetric?.count)),
|
|
2291
|
-
percentage:
|
|
2291
|
+
percentage: toNumberOrNaN(workOrderMetric?.percentage),
|
|
2292
2292
|
meta: getMetricMeta(workOrderMetric),
|
|
2293
2293
|
raw: workOrderMetric,
|
|
2294
2294
|
filter: undefined,
|
|
@@ -2300,7 +2300,7 @@ function getPropertyKpiCards() {
|
|
|
2300
2300
|
color: KPI_TONES.err.color,
|
|
2301
2301
|
tint: KPI_TONES.err.tint,
|
|
2302
2302
|
value: formatNumber(toNumber(incidentMetric?.count)),
|
|
2303
|
-
percentage:
|
|
2303
|
+
percentage: toNumberOrNaN(incidentMetric?.percentage),
|
|
2304
2304
|
meta: getMetricMeta(incidentMetric),
|
|
2305
2305
|
raw: incidentMetric,
|
|
2306
2306
|
filter: undefined,
|
|
@@ -2312,7 +2312,7 @@ function getPropertyKpiCards() {
|
|
|
2312
2312
|
color: KPI_TONES.warn.color,
|
|
2313
2313
|
tint: KPI_TONES.warn.tint,
|
|
2314
2314
|
value: formatNumber(toNumber(visitorMetric?.count)),
|
|
2315
|
-
percentage:
|
|
2315
|
+
percentage: toNumberOrNaN(visitorMetric?.percentage),
|
|
2316
2316
|
meta: getMetricMeta(visitorMetric),
|
|
2317
2317
|
raw: visitorMetric,
|
|
2318
2318
|
filter: {
|
|
@@ -2331,7 +2331,7 @@ function getPropertyKpiCards() {
|
|
|
2331
2331
|
color: KPI_TONES.ok.color,
|
|
2332
2332
|
tint: KPI_TONES.ok.tint,
|
|
2333
2333
|
value: formatNumber(toNumber(facilityMetric?.count)),
|
|
2334
|
-
percentage:
|
|
2334
|
+
percentage: toNumberOrNaN(facilityMetric?.percentage),
|
|
2335
2335
|
meta: getMetricMeta(facilityMetric),
|
|
2336
2336
|
raw: facilityMetric,
|
|
2337
2337
|
filter: undefined,
|
|
@@ -2366,7 +2366,7 @@ function getSecurityKpiCards() {
|
|
|
2366
2366
|
color: KPI_TONES.info.color,
|
|
2367
2367
|
tint: KPI_TONES.info.tint,
|
|
2368
2368
|
value: formatNumber(toNumber(workOrderMetric?.count)),
|
|
2369
|
-
percentage:
|
|
2369
|
+
percentage: toNumberOrNaN(workOrderMetric?.percentage),
|
|
2370
2370
|
meta: getMetricMeta(workOrderMetric),
|
|
2371
2371
|
raw: workOrderMetric,
|
|
2372
2372
|
filter: undefined,
|
|
@@ -2378,7 +2378,7 @@ function getSecurityKpiCards() {
|
|
|
2378
2378
|
color: KPI_TONES.err.color,
|
|
2379
2379
|
tint: KPI_TONES.err.tint,
|
|
2380
2380
|
value: formatNumber(toNumber(incidentMetric?.count)),
|
|
2381
|
-
percentage:
|
|
2381
|
+
percentage: toNumberOrNaN(incidentMetric?.percentage),
|
|
2382
2382
|
meta: getMetricMeta(incidentMetric),
|
|
2383
2383
|
raw: incidentMetric,
|
|
2384
2384
|
filter: undefined,
|
|
@@ -2390,7 +2390,7 @@ function getSecurityKpiCards() {
|
|
|
2390
2390
|
color: KPI_TONES.warn.color,
|
|
2391
2391
|
tint: KPI_TONES.warn.tint,
|
|
2392
2392
|
value: formatNumber(toNumber(visitorMetric?.count)),
|
|
2393
|
-
percentage:
|
|
2393
|
+
percentage: toNumberOrNaN(visitorMetric?.percentage),
|
|
2394
2394
|
meta: getMetricMeta(visitorMetric),
|
|
2395
2395
|
raw: visitorMetric,
|
|
2396
2396
|
filter: {
|
|
@@ -2408,8 +2408,10 @@ function getSecurityKpiCards() {
|
|
|
2408
2408
|
icon: "mdi-office-building",
|
|
2409
2409
|
color: KPI_TONES.ok.color,
|
|
2410
2410
|
tint: KPI_TONES.ok.tint,
|
|
2411
|
-
|
|
2412
|
-
|
|
2411
|
+
// Through the shared helper, so a `null` rate (no checkpoint was due)
|
|
2412
|
+
// reads as not-measured here too, not as 0%.
|
|
2413
|
+
value: getMetricValue(patrolMetric, "patrolCompliance"),
|
|
2414
|
+
percentage: toNumberOrNaN(patrolMetric?.percentage),
|
|
2413
2415
|
meta: getMetricMeta(patrolMetric),
|
|
2414
2416
|
raw: patrolMetric,
|
|
2415
2417
|
filter: undefined,
|
|
@@ -2436,7 +2438,7 @@ const rawKpiCards = computed(() => {
|
|
|
2436
2438
|
tint: getCardTint(mapping.color, index),
|
|
2437
2439
|
color: mapping.color || getCardColor(index),
|
|
2438
2440
|
value: typeof value === "string" ? value : formatNumber(value),
|
|
2439
|
-
percentage:
|
|
2441
|
+
percentage: toNumberOrNaN(metric.percentage),
|
|
2440
2442
|
meta: getMetricMeta(metric),
|
|
2441
2443
|
raw: metric,
|
|
2442
2444
|
filter: undefined as any,
|
|
@@ -2917,7 +2919,9 @@ function getMetric(keys: string[]): DashboardMetric {
|
|
|
2917
2919
|
|
|
2918
2920
|
function getMetricValue(metric: DashboardMetric, key?: string) {
|
|
2919
2921
|
if (key === "patrolCompliance") {
|
|
2920
|
-
|
|
2922
|
+
const rate = toNumberOrNaN(metric.count);
|
|
2923
|
+
// `null` = no checkpoint was due in this period. Not 0%, not 100%.
|
|
2924
|
+
return Number.isFinite(rate) ? `${formatNumber(rate)}%` : "—";
|
|
2921
2925
|
}
|
|
2922
2926
|
const completed = toNumber(metric.completed);
|
|
2923
2927
|
const total = toNumber(metric.total);
|
|
@@ -2935,11 +2939,18 @@ function getMetricMeta(metric: DashboardMetric) {
|
|
|
2935
2939
|
parts.push(`${formatNumber(toNumber(metric.inProgress))} in progress`);
|
|
2936
2940
|
if (typeof metric.pending !== "undefined")
|
|
2937
2941
|
parts.push(`${formatNumber(toNumber(metric.pending))} pending`);
|
|
2938
|
-
|
|
2942
|
+
// `null` here is the API saying incident severity is not recorded at all.
|
|
2943
|
+
// Printing "0 high severity" would answer a question nothing measured.
|
|
2944
|
+
if (metric.highSeverity !== undefined && metric.highSeverity !== null)
|
|
2939
2945
|
parts.push(`${formatNumber(toNumber(metric.highSeverity))} high severity`);
|
|
2940
|
-
|
|
2946
|
+
// NOT a live occupancy figure. The server counts every visitor transaction
|
|
2947
|
+
// with a check-in and no check-out, with NO date bound, and nothing in the
|
|
2948
|
+
// product ever closes a stale one - so a pass left open years ago is still
|
|
2949
|
+
// in here. Say what it counts. Same wording the Visitor Management screen
|
|
2950
|
+
// already uses for the same filter ("Not Checked Out").
|
|
2951
|
+
if (metric.currentlyOnSite !== undefined && metric.currentlyOnSite !== null)
|
|
2941
2952
|
parts.push(
|
|
2942
|
-
`${formatNumber(toNumber(metric.currentlyOnSite))}
|
|
2953
|
+
`${formatNumber(toNumber(metric.currentlyOnSite))} not checked out (all time)`
|
|
2943
2954
|
);
|
|
2944
2955
|
if (
|
|
2945
2956
|
typeof (metric as any).missedCheckpoints !== "undefined" ||
|
|
@@ -3905,6 +3916,21 @@ function toNumber(value: any) {
|
|
|
3905
3916
|
return Number.isFinite(n) ? n : 0;
|
|
3906
3917
|
}
|
|
3907
3918
|
|
|
3919
|
+
/**
|
|
3920
|
+
* A figure the server may report as "not measured".
|
|
3921
|
+
*
|
|
3922
|
+
* `toNumber()` turns `null` into `0`, which is wrong for anything the API
|
|
3923
|
+
* deliberately sends as `null` - a trend with no prior period to compare
|
|
3924
|
+
* against, a compliance rate with no checkpoint due, a severity the incident
|
|
3925
|
+
* form never records. Those must read as absent, not as zero. `NaN` is what
|
|
3926
|
+
* the tile already treats as "no pill" / "no figure".
|
|
3927
|
+
*/
|
|
3928
|
+
function toNumberOrNaN(value: any) {
|
|
3929
|
+
if (value === null || value === undefined || value === "") return NaN;
|
|
3930
|
+
const n = Number(value);
|
|
3931
|
+
return Number.isFinite(n) ? n : NaN;
|
|
3932
|
+
}
|
|
3933
|
+
|
|
3908
3934
|
function normalizeStatus(value: any) {
|
|
3909
3935
|
const text = String(value || "").trim();
|
|
3910
3936
|
if (!text) return "To-Do";
|
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
</template>
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
<!-- A failed request is not an empty result. Saying "no patrol log found"
|
|
21
|
+
for a 500 states something about the data the server never said. -->
|
|
22
|
+
<ReportEmptyState
|
|
23
|
+
v-else-if="!loading && failed"
|
|
24
|
+
title="Report unavailable"
|
|
25
|
+
message="The patrol logs could not be loaded. Please try again."
|
|
26
|
+
/>
|
|
20
27
|
<ReportEmptyState
|
|
21
28
|
v-else-if="!loading && notFound"
|
|
22
29
|
title="No Daily Report"
|
|
@@ -51,7 +58,7 @@ defineEmits<{
|
|
|
51
58
|
export: [type: "pdf" | "csv"];
|
|
52
59
|
}>();
|
|
53
60
|
|
|
54
|
-
const { report, loading, notFound, fetchReport } = useNFCPatrolDailyReport(
|
|
61
|
+
const { report, loading, notFound, failed, fetchReport } = useNFCPatrolDailyReport(
|
|
55
62
|
props.filters,
|
|
56
63
|
props.site,
|
|
57
64
|
);
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
/>
|
|
22
22
|
</template>
|
|
23
23
|
|
|
24
|
+
<!-- A failed request is not an empty result. Saying "no patrol log found"
|
|
25
|
+
for a 500 states something about the data the server never said. -->
|
|
26
|
+
<ReportEmptyState
|
|
27
|
+
v-else-if="!loading && failed"
|
|
28
|
+
title="Report unavailable"
|
|
29
|
+
message="The patrol logs could not be loaded. Please try again."
|
|
30
|
+
/>
|
|
24
31
|
<ReportEmptyState
|
|
25
32
|
v-else-if="!loading"
|
|
26
33
|
title="No Monthly Report"
|
|
@@ -55,7 +62,7 @@ defineEmits<{
|
|
|
55
62
|
export: [type: "pdf" | "csv"];
|
|
56
63
|
}>();
|
|
57
64
|
|
|
58
|
-
const { report, loading, notFound, fetchReport } =
|
|
65
|
+
const { report, loading, notFound, failed, fetchReport } =
|
|
59
66
|
useNFCPatrolMonthlyReport(props.filters, props.site);
|
|
60
67
|
|
|
61
68
|
watch(
|
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
{{ row.checked }}
|
|
27
27
|
</td>
|
|
28
28
|
|
|
29
|
+
<!-- A month with no checkpoint due has no rate. "0%" there reads as
|
|
30
|
+
total failure, so it says nothing instead. -->
|
|
29
31
|
<td class="text-center">
|
|
30
|
-
{{ row.checkedPercentage }}
|
|
32
|
+
{{ row.checkedPercentage === null ? "—" : `${row.checkedPercentage}%` }}
|
|
31
33
|
</td>
|
|
32
34
|
|
|
33
35
|
<td class="text-center">
|
|
@@ -35,7 +37,7 @@
|
|
|
35
37
|
</td>
|
|
36
38
|
|
|
37
39
|
<td class="text-center">
|
|
38
|
-
{{ row.missedPercentage }}
|
|
40
|
+
{{ row.missedPercentage === null ? "—" : `${row.missedPercentage}%` }}
|
|
39
41
|
</td>
|
|
40
42
|
</tr>
|
|
41
43
|
</tbody>
|
|
@@ -22,6 +22,13 @@
|
|
|
22
22
|
/>
|
|
23
23
|
</template>
|
|
24
24
|
|
|
25
|
+
<!-- A failed request is not an empty result. Saying "no patrol log found"
|
|
26
|
+
for a 500 states something about the data the server never said. -->
|
|
27
|
+
<ReportEmptyState
|
|
28
|
+
v-else-if="!loading && failed"
|
|
29
|
+
title="Report unavailable"
|
|
30
|
+
message="The patrol logs could not be loaded. Please try again."
|
|
31
|
+
/>
|
|
25
32
|
<ReportEmptyState
|
|
26
33
|
v-else-if="!loading && notFound"
|
|
27
34
|
title="No Patrol Log"
|
|
@@ -63,6 +70,7 @@ const {
|
|
|
63
70
|
report,
|
|
64
71
|
loading,
|
|
65
72
|
notFound,
|
|
73
|
+
failed,
|
|
66
74
|
selectedActivityRemarks,
|
|
67
75
|
dialogRemarks,
|
|
68
76
|
fetchReport,
|
|
@@ -52,16 +52,24 @@ export function useNFCPatrolDailyReport(
|
|
|
52
52
|
const report = ref<NFCPatrolSummaryReport | null>(null);
|
|
53
53
|
const loading = ref(false);
|
|
54
54
|
const notFound = ref(false);
|
|
55
|
+
/**
|
|
56
|
+
* A request that FAILED is not a route with no patrols. Without this the
|
|
57
|
+
* screen answered a 500 with "No patrol log found for this route", which is
|
|
58
|
+
* a statement about the data the server never made.
|
|
59
|
+
*/
|
|
60
|
+
const failed = ref(false);
|
|
55
61
|
|
|
56
62
|
async function fetchReport() {
|
|
57
63
|
if (!site || !filters.date || !filters.route) {
|
|
58
64
|
report.value = null;
|
|
59
65
|
notFound.value = false;
|
|
66
|
+
failed.value = false;
|
|
60
67
|
return;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
loading.value = true;
|
|
64
71
|
notFound.value = false;
|
|
72
|
+
failed.value = false;
|
|
65
73
|
|
|
66
74
|
try {
|
|
67
75
|
const [logsRes, siteInfo] = await Promise.all([
|
|
@@ -92,6 +100,10 @@ export function useNFCPatrolDailyReport(
|
|
|
92
100
|
report.value = null;
|
|
93
101
|
notFound.value = true;
|
|
94
102
|
}
|
|
103
|
+
} catch {
|
|
104
|
+
report.value = null;
|
|
105
|
+
notFound.value = false;
|
|
106
|
+
failed.value = true;
|
|
95
107
|
} finally {
|
|
96
108
|
loading.value = false;
|
|
97
109
|
}
|
|
@@ -101,6 +113,7 @@ export function useNFCPatrolDailyReport(
|
|
|
101
113
|
report,
|
|
102
114
|
loading,
|
|
103
115
|
notFound,
|
|
116
|
+
failed,
|
|
104
117
|
fetchReport,
|
|
105
118
|
};
|
|
106
119
|
}
|
|
@@ -2,11 +2,11 @@ import type {
|
|
|
2
2
|
NFCPatrolReportFilters,
|
|
3
3
|
NFCPatrolSummaryReport,
|
|
4
4
|
NFCPatrolReportSummaryRow,
|
|
5
|
-
NFCPatrolMonthlyReportRow,
|
|
6
5
|
NFCPatrolMonthlyReport,
|
|
7
6
|
} from "../types/nfc-patrol-report";
|
|
8
7
|
import useNFCPatrolLog from "./useNFCPatrolLog";
|
|
9
8
|
import useSite from "./useSite";
|
|
9
|
+
import { collectAllPages, mapLogsToMonthlyRows } from "../utils/nfc-patrol-report";
|
|
10
10
|
|
|
11
11
|
interface NFCPatrolLogListResponse {
|
|
12
12
|
items?: Record<string, any>[];
|
|
@@ -14,55 +14,6 @@ interface NFCPatrolLogListResponse {
|
|
|
14
14
|
pageRange?: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const MONTHS = [
|
|
18
|
-
"January",
|
|
19
|
-
"February",
|
|
20
|
-
"March",
|
|
21
|
-
"April",
|
|
22
|
-
"May",
|
|
23
|
-
"June",
|
|
24
|
-
"July",
|
|
25
|
-
"August",
|
|
26
|
-
"September",
|
|
27
|
-
"October",
|
|
28
|
-
"November",
|
|
29
|
-
"December",
|
|
30
|
-
];
|
|
31
|
-
function mapLogsToMonthlyRows(
|
|
32
|
-
logs: Record<string, any>[],
|
|
33
|
-
): NFCPatrolMonthlyReportRow[] {
|
|
34
|
-
const rows = MONTHS.map((month) => ({
|
|
35
|
-
month,
|
|
36
|
-
checked: 0,
|
|
37
|
-
checkedPercentage: 0,
|
|
38
|
-
missed: 0,
|
|
39
|
-
missedPercentage: 0,
|
|
40
|
-
}));
|
|
41
|
-
|
|
42
|
-
logs.forEach((log) => {
|
|
43
|
-
const monthIndex = new Date(log.date).getMonth();
|
|
44
|
-
|
|
45
|
-
for (const checkpoint of log.checkPoints ?? []) {
|
|
46
|
-
if (checkpoint.status === "Completed") {
|
|
47
|
-
rows[monthIndex].checked++;
|
|
48
|
-
} else if (checkpoint.status === "Skipped") {
|
|
49
|
-
rows[monthIndex].missed++;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
rows.forEach((row) => {
|
|
55
|
-
const total = row.checked + row.missed;
|
|
56
|
-
|
|
57
|
-
if (total > 0) {
|
|
58
|
-
row.checkedPercentage = Math.round((row.checked / total) * 100);
|
|
59
|
-
row.missedPercentage = Math.round((row.missed / total) * 100);
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
return rows;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
17
|
function buildAddress(address?: Record<string, string>) {
|
|
67
18
|
if (!address) return undefined;
|
|
68
19
|
|
|
@@ -90,32 +41,42 @@ export function useNFCPatrolMonthlyReport(
|
|
|
90
41
|
const report = ref<NFCPatrolMonthlyReport | null>(null);
|
|
91
42
|
const loading = ref(false);
|
|
92
43
|
const notFound = ref(false);
|
|
44
|
+
/**
|
|
45
|
+
* A request that FAILED is not a route with no patrols. Without this the
|
|
46
|
+
* screen answered a 500 with "No patrol log found for this route", which is
|
|
47
|
+
* a statement about the data the server never made.
|
|
48
|
+
*/
|
|
49
|
+
const failed = ref(false);
|
|
93
50
|
|
|
94
51
|
async function fetchReport() {
|
|
95
52
|
if (!site || !filters.route) {
|
|
96
53
|
report.value = null;
|
|
97
54
|
notFound.value = false;
|
|
55
|
+
failed.value = false;
|
|
98
56
|
return;
|
|
99
57
|
}
|
|
100
58
|
|
|
101
59
|
loading.value = true;
|
|
102
60
|
notFound.value = false;
|
|
61
|
+
failed.value = false;
|
|
103
62
|
const currentYear = new Date().getFullYear().toString();
|
|
104
63
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
64
|
+
// The whole year, not page one of it. `collectAllPages` reads the page
|
|
65
|
+
// count out of the first reply and walks the rest.
|
|
66
|
+
const [logs, siteInfo] = await Promise.all([
|
|
67
|
+
collectAllPages((page) =>
|
|
68
|
+
getPatrolLogs({
|
|
69
|
+
page,
|
|
108
70
|
limit: 100,
|
|
109
71
|
site,
|
|
110
72
|
routeId: filters.route,
|
|
111
73
|
date: currentYear,
|
|
112
74
|
type: "month",
|
|
113
|
-
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
114
77
|
getSiteById(site),
|
|
115
78
|
]);
|
|
116
79
|
|
|
117
|
-
const logs = logsRes.items ?? [];
|
|
118
|
-
|
|
119
80
|
if (logs.length) {
|
|
120
81
|
const routeName = logs[0]?.route?.name ?? "-";
|
|
121
82
|
|
|
@@ -131,6 +92,10 @@ export function useNFCPatrolMonthlyReport(
|
|
|
131
92
|
report.value = null;
|
|
132
93
|
notFound.value = true;
|
|
133
94
|
}
|
|
95
|
+
} catch {
|
|
96
|
+
report.value = null;
|
|
97
|
+
notFound.value = false;
|
|
98
|
+
failed.value = true;
|
|
134
99
|
} finally {
|
|
135
100
|
loading.value = false;
|
|
136
101
|
}
|
|
@@ -140,6 +105,7 @@ export function useNFCPatrolMonthlyReport(
|
|
|
140
105
|
report,
|
|
141
106
|
loading,
|
|
142
107
|
notFound,
|
|
108
|
+
failed,
|
|
143
109
|
fetchReport,
|
|
144
110
|
};
|
|
145
111
|
}
|
|
@@ -91,6 +91,12 @@ export function useNFCPatrolReport(
|
|
|
91
91
|
const report = ref<NFCPatrolReport | null>(null);
|
|
92
92
|
const loading = ref(false);
|
|
93
93
|
const notFound = ref(false);
|
|
94
|
+
/**
|
|
95
|
+
* A request that FAILED is not a route with no patrols. Without this the
|
|
96
|
+
* screen answered a 500 with "No patrol log found for this route", which is
|
|
97
|
+
* a statement about the data the server never made.
|
|
98
|
+
*/
|
|
99
|
+
const failed = ref(false);
|
|
94
100
|
const selectedActivityRemarks = ref("");
|
|
95
101
|
const dialogRemarks = ref(false);
|
|
96
102
|
|
|
@@ -98,11 +104,13 @@ export function useNFCPatrolReport(
|
|
|
98
104
|
if (!site || !filters.date || !filters.route || !filters.timeRange) {
|
|
99
105
|
report.value = null;
|
|
100
106
|
notFound.value = false;
|
|
107
|
+
failed.value = false;
|
|
101
108
|
return;
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
loading.value = true;
|
|
105
112
|
notFound.value = false;
|
|
113
|
+
failed.value = false;
|
|
106
114
|
|
|
107
115
|
try {
|
|
108
116
|
const [logsRes, siteInfo] = await Promise.all([
|
|
@@ -128,6 +136,10 @@ export function useNFCPatrolReport(
|
|
|
128
136
|
report.value = null;
|
|
129
137
|
notFound.value = true;
|
|
130
138
|
}
|
|
139
|
+
} catch {
|
|
140
|
+
report.value = null;
|
|
141
|
+
notFound.value = false;
|
|
142
|
+
failed.value = true;
|
|
131
143
|
} finally {
|
|
132
144
|
loading.value = false;
|
|
133
145
|
}
|
|
@@ -165,6 +177,7 @@ export function useNFCPatrolReport(
|
|
|
165
177
|
report,
|
|
166
178
|
loading,
|
|
167
179
|
notFound,
|
|
180
|
+
failed,
|
|
168
181
|
selectedActivityRemarks,
|
|
169
182
|
dialogRemarks,
|
|
170
183
|
fetchReport,
|
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.190",
|
|
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.",
|
|
@@ -85,9 +85,10 @@ export interface NFCPatrolSummaryReport {
|
|
|
85
85
|
export interface NFCPatrolMonthlyReportRow {
|
|
86
86
|
month: string;
|
|
87
87
|
checked: number;
|
|
88
|
-
|
|
88
|
+
/** `null` when no checkpoint was due that month - see `utils/nfc-patrol-report.ts`. */
|
|
89
|
+
checkedPercentage: number | null;
|
|
89
90
|
missed: number;
|
|
90
|
-
missedPercentage: number;
|
|
91
|
+
missedPercentage: number | null;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
export interface NFCPatrolMonthlyReport {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { collectAllPages, mapLogsToMonthlyRows } from "./nfc-patrol-report.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* THE DEFECT THIS FILE EXISTS FOR.
|
|
8
|
+
*
|
|
9
|
+
* The yearly patrol-compliance table was built from `page: 1, limit: 100` of a
|
|
10
|
+
* paged endpoint and never looked at `pages`. A route patrolled daily produces
|
|
11
|
+
* ~300 runs a year, so months past the first page rendered "0 / 0% / 0 / 0%" -
|
|
12
|
+
* indistinguishable from a month in which every checkpoint was missed, in a
|
|
13
|
+
* table a manager can print and hand to a client.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 12 months x 25 runs x (4 completed + 1 skipped). Every month is 80%/20%. */
|
|
17
|
+
function yearOfLogs() {
|
|
18
|
+
const logs: Record<string, any>[] = [];
|
|
19
|
+
for (let m = 0; m < 12; m++) {
|
|
20
|
+
for (let d = 1; d <= 25; d++) {
|
|
21
|
+
const checkPoints = [
|
|
22
|
+
{ status: "Completed" },
|
|
23
|
+
{ status: "Completed" },
|
|
24
|
+
{ status: "Completed" },
|
|
25
|
+
{ status: "Completed" },
|
|
26
|
+
{ status: "Skipped" },
|
|
27
|
+
];
|
|
28
|
+
logs.push({
|
|
29
|
+
date: `2026-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T08:00:00.000Z`,
|
|
30
|
+
checkPoints,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return logs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pagedFetcher(all: Record<string, any>[], limit = 100) {
|
|
38
|
+
const pages = Math.ceil(all.length / limit);
|
|
39
|
+
const seen: number[] = [];
|
|
40
|
+
return {
|
|
41
|
+
seen,
|
|
42
|
+
fetchPage: async (page: number) => {
|
|
43
|
+
seen.push(page);
|
|
44
|
+
return { items: all.slice((page - 1) * limit, page * limit), pages };
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("every page the endpoint reports is read, not just the first", async () => {
|
|
50
|
+
const all = yearOfLogs();
|
|
51
|
+
const { fetchPage, seen } = pagedFetcher(all);
|
|
52
|
+
|
|
53
|
+
const items = await collectAllPages(fetchPage);
|
|
54
|
+
|
|
55
|
+
assert.equal(items.length, all.length, "all 300 runs collected");
|
|
56
|
+
assert.deepEqual(seen, [1, 2, 3], "walked every page the reply declared");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a full year reports the same rate in every month", async () => {
|
|
60
|
+
const items = await collectAllPages(pagedFetcher(yearOfLogs()).fetchPage);
|
|
61
|
+
const rows = mapLogsToMonthlyRows(items);
|
|
62
|
+
|
|
63
|
+
assert.equal(rows.length, 12);
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
assert.equal(row.checked, 100, `${row.month} checked`);
|
|
66
|
+
assert.equal(row.missed, 25, `${row.month} missed`);
|
|
67
|
+
assert.equal(row.checkedPercentage, 80, `${row.month} % checked`);
|
|
68
|
+
assert.equal(row.missedPercentage, 20, `${row.month} % missed`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("only the first page would have zeroed the rest of the year", () => {
|
|
73
|
+
// What the screen used to do: one page of 100, the other 200 runs dropped.
|
|
74
|
+
const firstPageOnly = yearOfLogs().slice(0, 100);
|
|
75
|
+
const rows = mapLogsToMonthlyRows(firstPageOnly);
|
|
76
|
+
|
|
77
|
+
assert.equal(rows[0].checkedPercentage, 80, "January measured");
|
|
78
|
+
assert.equal(rows[4].checked, 0, "May had no runs on page 1");
|
|
79
|
+
assert.equal(
|
|
80
|
+
rows[4].checkedPercentage,
|
|
81
|
+
null,
|
|
82
|
+
"and it must NOT read 0% - nothing was measured",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("a month with no checkpoint due reports no rate at all", () => {
|
|
87
|
+
const rows = mapLogsToMonthlyRows([]);
|
|
88
|
+
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
assert.equal(row.checked, 0);
|
|
91
|
+
assert.equal(row.missed, 0);
|
|
92
|
+
assert.equal(row.checkedPercentage, null);
|
|
93
|
+
assert.equal(row.missedPercentage, null);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a month where every checkpoint was missed still reports 0%", () => {
|
|
98
|
+
const rows = mapLogsToMonthlyRows([
|
|
99
|
+
{ date: "2026-03-04T08:00:00.000Z", checkPoints: [{ status: "Skipped" }, { status: "Skipped" }] },
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
assert.equal(rows[2].checkedPercentage, 0, "0% here is a real measurement");
|
|
103
|
+
assert.equal(rows[2].missedPercentage, 100);
|
|
104
|
+
assert.equal(rows[0].checkedPercentage, null, "January measured nothing");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a single-page reply is not re-fetched", async () => {
|
|
108
|
+
const { fetchPage, seen } = pagedFetcher(yearOfLogs().slice(0, 10));
|
|
109
|
+
await collectAllPages(fetchPage);
|
|
110
|
+
assert.deepEqual(seen, [1]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a reply with no page count is treated as one page", async () => {
|
|
114
|
+
const calls: number[] = [];
|
|
115
|
+
const items = await collectAllPages(async (page) => {
|
|
116
|
+
calls.push(page);
|
|
117
|
+
return { items: [{ date: "2026-01-02T00:00:00.000Z", checkPoints: [] }] };
|
|
118
|
+
});
|
|
119
|
+
assert.deepEqual(calls, [1]);
|
|
120
|
+
assert.equal(items.length, 1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("undated or malformed logs are skipped, not counted into January", () => {
|
|
124
|
+
const rows = mapLogsToMonthlyRows([
|
|
125
|
+
{ date: "not-a-date", checkPoints: [{ status: "Completed" }] },
|
|
126
|
+
{ checkPoints: [{ status: "Completed" }] },
|
|
127
|
+
]);
|
|
128
|
+
assert.equal(rows[0].checked, 0);
|
|
129
|
+
assert.equal(rows[0].checkedPercentage, null);
|
|
130
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patrol-report arithmetic that is worth testing on its own.
|
|
3
|
+
*
|
|
4
|
+
* The yearly compliance table used to be built from `page: 1, limit: 100` of a
|
|
5
|
+
* paged endpoint. A route patrolled daily produces far more than 100 runs a
|
|
6
|
+
* year, so every month past the first page rendered "0 / 0% / 0 / 0%" - eight
|
|
7
|
+
* months of measured zeros in a table a manager can print and hand to a
|
|
8
|
+
* client. The page walk and the month mapping live here so they have tests.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** One page of the patrol-log endpoint's reply. */
|
|
12
|
+
export type TPatrolLogPage = {
|
|
13
|
+
items?: Record<string, any>[];
|
|
14
|
+
pages?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type TPatrolMonthRow = {
|
|
18
|
+
month: string;
|
|
19
|
+
checked: number;
|
|
20
|
+
missed: number;
|
|
21
|
+
/** `null` when no checkpoint was due that month - a rate over nothing is not a measurement. */
|
|
22
|
+
checkedPercentage: number | null;
|
|
23
|
+
missedPercentage: number | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const MONTHS = [
|
|
27
|
+
"January",
|
|
28
|
+
"February",
|
|
29
|
+
"March",
|
|
30
|
+
"April",
|
|
31
|
+
"May",
|
|
32
|
+
"June",
|
|
33
|
+
"July",
|
|
34
|
+
"August",
|
|
35
|
+
"September",
|
|
36
|
+
"October",
|
|
37
|
+
"November",
|
|
38
|
+
"December",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read every page the endpoint says exists, not just the first.
|
|
43
|
+
*
|
|
44
|
+
* ponytail: sequential. A year of one route is a handful of pages, and firing
|
|
45
|
+
* them in parallel would only trade a few hundred ms for a burst on the API.
|
|
46
|
+
* `maxPages` is a runaway guard, not a product limit - if it ever bites, the
|
|
47
|
+
* caller is asking for a range the report was not designed for.
|
|
48
|
+
*/
|
|
49
|
+
export async function collectAllPages(
|
|
50
|
+
fetchPage: (page: number) => Promise<TPatrolLogPage>,
|
|
51
|
+
maxPages = 50,
|
|
52
|
+
): Promise<Record<string, any>[]> {
|
|
53
|
+
const first = await fetchPage(1);
|
|
54
|
+
const items = [...(first?.items ?? [])];
|
|
55
|
+
const pages = Math.min(Number(first?.pages) || 1, maxPages);
|
|
56
|
+
|
|
57
|
+
for (let page = 2; page <= pages; page++) {
|
|
58
|
+
const next = await fetchPage(page);
|
|
59
|
+
items.push(...(next?.items ?? []));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return items;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One row per calendar month. A month with no checkpoint due keeps its zero
|
|
67
|
+
* counts - zero checkpoints really were checked - but reports `null` for both
|
|
68
|
+
* percentages, because a percentage of nothing is not a measurement and "0%"
|
|
69
|
+
* reads exactly like total failure.
|
|
70
|
+
*/
|
|
71
|
+
export function mapLogsToMonthlyRows(
|
|
72
|
+
logs: Record<string, any>[],
|
|
73
|
+
): TPatrolMonthRow[] {
|
|
74
|
+
const rows: TPatrolMonthRow[] = MONTHS.map((month) => ({
|
|
75
|
+
month,
|
|
76
|
+
checked: 0,
|
|
77
|
+
missed: 0,
|
|
78
|
+
checkedPercentage: null,
|
|
79
|
+
missedPercentage: null,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
for (const log of logs) {
|
|
83
|
+
const monthIndex = new Date(log?.date).getMonth();
|
|
84
|
+
if (Number.isNaN(monthIndex)) continue;
|
|
85
|
+
|
|
86
|
+
for (const checkpoint of log?.checkPoints ?? []) {
|
|
87
|
+
if (checkpoint?.status === "Completed") rows[monthIndex].checked++;
|
|
88
|
+
else if (checkpoint?.status === "Skipped") rows[monthIndex].missed++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
const total = row.checked + row.missed;
|
|
94
|
+
if (total > 0) {
|
|
95
|
+
row.checkedPercentage = Math.round((row.checked / total) * 100);
|
|
96
|
+
row.missedPercentage = Math.round((row.missed / total) * 100);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return rows;
|
|
101
|
+
}
|