@7365admin1/layer-common 4.141.0 → 4.143.0
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 +16 -4
- package/components/Nfc/NFCPatrolRouteForm.vue +81 -13
- package/components/Nfc/NFCPatrolRouteMain.vue +17 -5
- package/components/Nfc/PatrolReport/SummaryReportTable.vue +1 -0
- package/composables/useNFCPatrolReportFilters.ts +55 -15
- package/composables/useNFCPatrolRoute.ts +4 -2
- package/nuxt.config.ts +4 -0
- package/package.json +1 -1
- package/plugins/API.ts +31 -1
- package/plugins/client-logs.client.ts +47 -0
- package/types/nfc-patrol-report.ts +4 -2
- package/utils/client-log.ts +71 -0
- package/utils/nfc-patrol-report.ts +47 -10
- package/utils/nfc-patrol-schedule.ts +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.143.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 87617ca: NFC patrol: route timing On/Off switch on the route form (Off = no start times or tolerance, patrol at any time; routes list and preview show "Anytime (timing off)"); "Select all / Unselect all" for the route's days; any number of checkpoints (was a 1-10 list) and every tag of the site offered (was the first 100); tolerance sent as a number. Reports: a checkpoint still Pending after its timed round's window closed counts as missed and the round reads "Incomplete" (was "Completed, 0 missed"), a round still open reads "In progress", untimed rounds are never missed for time and show "Anytime"; the Patrol tab offers an untimed route's rounds of the day; the report's route picker reads every page. Routes search returns to page 1.
|
|
8
|
+
|
|
9
|
+
## 4.142.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 38e504d: Every app now reports browser crashes, failed API calls and successful saves to the Seven365 Logger through API-core (POST /api/client-logs), filed under the app's own folder.
|
|
14
|
+
|
|
3
15
|
## 4.141.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -3401,11 +3413,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
|
|
|
3401
3413
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
3402
3414
|
own rule from two endpoints the console already calls, unprojected:
|
|
3403
3415
|
|
|
3404
|
-
|
|
3405
|
-
|
|
3416
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
3417
|
+
GET /api/roles/id/:role that membership's role document
|
|
3406
3418
|
|
|
3407
|
-
|
|
3408
|
-
|
|
3419
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
3420
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
3409
3421
|
|
|
3410
3422
|
`role.default` is the marker because it is the only property of a platform
|
|
3411
3423
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -33,17 +33,21 @@
|
|
|
33
33
|
<label class="text-caption screen-hint mb-1 d-block">
|
|
34
34
|
Checkpoints <span class="text-error">*</span>
|
|
35
35
|
</label>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
:
|
|
36
|
+
<!-- Any number of checkpoints (it was a 1-10 list). -->
|
|
37
|
+
<v-text-field
|
|
38
|
+
:model-value="form.checkPointNumber"
|
|
39
|
+
type="number"
|
|
40
|
+
min="1"
|
|
41
|
+
inputmode="numeric"
|
|
39
42
|
variant="outlined"
|
|
40
43
|
density="comfortable"
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
:rules="[rules.checkpointCount]"
|
|
45
|
+
hide-details="auto"
|
|
46
|
+
@update:model-value="onCheckpointCountInput"
|
|
43
47
|
/>
|
|
44
48
|
</v-col>
|
|
45
49
|
|
|
46
|
-
<v-col cols="12" md="4" class="mb-4 px-md-1">
|
|
50
|
+
<v-col v-if="form.timed" cols="12" md="4" class="mb-4 px-md-1">
|
|
47
51
|
<label class="text-caption screen-hint mb-1 d-block">
|
|
48
52
|
Tolerance (mins) <span class="text-error">*</span>
|
|
49
53
|
</label>
|
|
@@ -74,11 +78,46 @@
|
|
|
74
78
|
density="comfortable"
|
|
75
79
|
hide-details
|
|
76
80
|
chips
|
|
81
|
+
>
|
|
82
|
+
<template #prepend-item>
|
|
83
|
+
<v-list-item
|
|
84
|
+
:title="allDaysSelected ? 'Unselect all' : 'Select all'"
|
|
85
|
+
@click="toggleAllDays"
|
|
86
|
+
>
|
|
87
|
+
<template #prepend>
|
|
88
|
+
<v-checkbox-btn
|
|
89
|
+
:model-value="allDaysSelected"
|
|
90
|
+
:indeterminate="someDaysSelected"
|
|
91
|
+
@click.stop="toggleAllDays"
|
|
92
|
+
/>
|
|
93
|
+
</template>
|
|
94
|
+
</v-list-item>
|
|
95
|
+
<v-divider class="mt-1" />
|
|
96
|
+
</template>
|
|
97
|
+
</v-select>
|
|
98
|
+
</v-col>
|
|
99
|
+
|
|
100
|
+
<!-- Route timing. Off: no start times or tolerance, and the route can be
|
|
101
|
+
patrolled at any time of the day. -->
|
|
102
|
+
<v-col cols="12" class="mb-4">
|
|
103
|
+
<v-switch
|
|
104
|
+
v-model="form.timed"
|
|
105
|
+
color="primary"
|
|
106
|
+
inset
|
|
107
|
+
hide-details
|
|
108
|
+
:label="form.timed ? 'Route timing: On' : 'Route timing: Off'"
|
|
77
109
|
/>
|
|
110
|
+
<div class="text-caption screen-hint">
|
|
111
|
+
{{
|
|
112
|
+
form.timed
|
|
113
|
+
? "Patrols start at the route's start times, within the tolerance."
|
|
114
|
+
: "No time window: this route can be patrolled at any time."
|
|
115
|
+
}}
|
|
116
|
+
</div>
|
|
78
117
|
</v-col>
|
|
79
118
|
|
|
80
119
|
<!-- Start Times -->
|
|
81
|
-
<v-col cols="12" class="mb-4">
|
|
120
|
+
<v-col v-if="form.timed" cols="12" class="mb-4">
|
|
82
121
|
<v-card variant="outlined" class="pa-4" rounded="lg">
|
|
83
122
|
<div class="d-flex align-center justify-space-between mb-3">
|
|
84
123
|
<span class="text-h6 font-weight-medium">Start Times</span>
|
|
@@ -264,6 +303,8 @@
|
|
|
264
303
|
<script setup lang="ts">
|
|
265
304
|
import useNFCPatrolRoute from "../../composables/useNFCPatrolRoute";
|
|
266
305
|
import useNFCPatrolTag from "../../composables/useNFCPatrolTag";
|
|
306
|
+
import { collectAllPages } from "../../utils/nfc-patrol-report";
|
|
307
|
+
import { allDaysToggled, checkpointCountError } from "../../utils/nfc-patrol-schedule";
|
|
267
308
|
|
|
268
309
|
const props = defineProps({
|
|
269
310
|
title: {
|
|
@@ -329,11 +370,12 @@ const form = ref({
|
|
|
329
370
|
startTimes: ["08:00"] as string[],
|
|
330
371
|
endTimes: ["09:00"] as string[],
|
|
331
372
|
checkPoints: [] as Checkpoint[],
|
|
373
|
+
// Route timing; false = no time window (patrol at any time).
|
|
374
|
+
timed: true,
|
|
332
375
|
});
|
|
333
376
|
|
|
334
377
|
const startTimeKeys = ref<number[]>([Date.now()]);
|
|
335
378
|
|
|
336
|
-
const checkpointOptions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
|
337
379
|
const dayOptions = [
|
|
338
380
|
{ title: "Monday", value: 1 },
|
|
339
381
|
{ title: "Tuesday", value: 2 },
|
|
@@ -344,10 +386,29 @@ const dayOptions = [
|
|
|
344
386
|
{ title: "Sunday", value: 0 },
|
|
345
387
|
];
|
|
346
388
|
|
|
389
|
+
const allDaysSelected = computed(() => form.value.days.length === dayOptions.length);
|
|
390
|
+
const someDaysSelected = computed(
|
|
391
|
+
() => form.value.days.length > 0 && !allDaysSelected.value,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
function toggleAllDays() {
|
|
395
|
+
form.value.days = allDaysToggled(
|
|
396
|
+
form.value.days,
|
|
397
|
+
dayOptions.map((day) => day.value),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function onCheckpointCountInput(value: string | number) {
|
|
402
|
+
const count = Number(value);
|
|
403
|
+
form.value.checkPointNumber = count;
|
|
404
|
+
if (Number.isInteger(count) && count >= 1) updateCheckpointsCount(count);
|
|
405
|
+
}
|
|
406
|
+
|
|
347
407
|
const MAX_TOLERANCE_MINUTES = 60;
|
|
348
408
|
|
|
349
409
|
const rules = {
|
|
350
410
|
required: (v: any) => !!v || "This field is required",
|
|
411
|
+
checkpointCount: (v: any) => checkpointCountError(v) ?? true,
|
|
351
412
|
tolerance: (v: any) => {
|
|
352
413
|
if (!/^\d+$/.test(String(v ?? ""))) return "Numbers only";
|
|
353
414
|
if (Number(v) > MAX_TOLERANCE_MINUTES)
|
|
@@ -374,8 +435,11 @@ const { getAll: getAllNFCTags } = useNFCPatrolTag();
|
|
|
374
435
|
|
|
375
436
|
async function fetchNFCTags() {
|
|
376
437
|
try {
|
|
377
|
-
|
|
378
|
-
nfcTags.value =
|
|
438
|
+
// Every page: a site can have any number of tags (it read the first 100).
|
|
439
|
+
nfcTags.value = await collectAllPages(
|
|
440
|
+
(page) => getAllNFCTags({ site: props.site, page, limit: 100 }) as any,
|
|
441
|
+
1000,
|
|
442
|
+
);
|
|
379
443
|
} catch (error) {
|
|
380
444
|
console.error("Error fetching NFC tags:", error);
|
|
381
445
|
}
|
|
@@ -554,6 +618,7 @@ async function initializeEditForm(newRoute: any) {
|
|
|
554
618
|
form.value.startTimes = startTimes;
|
|
555
619
|
form.value.endTimes = endTimes;
|
|
556
620
|
form.value.checkPoints = existingCheckPoints;
|
|
621
|
+
form.value.timed = newRoute.timed !== false;
|
|
557
622
|
|
|
558
623
|
// Reinitialize keys for each start time
|
|
559
624
|
startTimeKeys.value = startTimes.map(() => Date.now() + Math.random());
|
|
@@ -598,14 +663,17 @@ async function submit() {
|
|
|
598
663
|
),
|
|
599
664
|
];
|
|
600
665
|
|
|
666
|
+
// Timing off keeps the tolerance as it was (the server requires one) but
|
|
667
|
+
// sends no start times: the route has no schedule.
|
|
601
668
|
const payload = {
|
|
602
669
|
name: form.value.name,
|
|
603
|
-
checkPointNumber: form.value.checkPointNumber,
|
|
604
|
-
tolerance: form.value.tolerance,
|
|
670
|
+
checkPointNumber: Number(form.value.checkPointNumber),
|
|
671
|
+
tolerance: Number(form.value.tolerance) || 0,
|
|
605
672
|
days: form.value.days,
|
|
606
|
-
startTimes: uniqueStartTimes,
|
|
673
|
+
startTimes: form.value.timed ? uniqueStartTimes : [],
|
|
607
674
|
checkPoints: form.value.checkPoints,
|
|
608
675
|
site: props.site,
|
|
676
|
+
timed: form.value.timed,
|
|
609
677
|
};
|
|
610
678
|
|
|
611
679
|
if (props.mode === "create") {
|
|
@@ -50,14 +50,15 @@
|
|
|
50
50
|
|
|
51
51
|
<template #item.tolerance="{ item }">
|
|
52
52
|
<div class="text-center">
|
|
53
|
-
{{ item.tolerance ? `${item.tolerance} mins` : "--" }}
|
|
53
|
+
{{ item.timed && item.tolerance ? `${item.tolerance} mins` : "--" }}
|
|
54
54
|
</div>
|
|
55
55
|
</template>
|
|
56
56
|
|
|
57
57
|
<!-- Earliest first, one unbreakable pill per window; past three the
|
|
58
58
|
rest fold into "+N more" so a busy route keeps a normal row. -->
|
|
59
|
-
<template #item.schedules="{ value }">
|
|
60
|
-
<
|
|
59
|
+
<template #item.schedules="{ item, value }">
|
|
60
|
+
<span v-if="!item.timed">Anytime (timing off)</span>
|
|
61
|
+
<NFCRouteScheduleTimes v-else :windows="value || []" :limit="3" />
|
|
61
62
|
</template>
|
|
62
63
|
|
|
63
64
|
<template #item.repeatDays="{ value }">
|
|
@@ -132,7 +133,12 @@
|
|
|
132
133
|
</span>
|
|
133
134
|
</v-col>
|
|
134
135
|
|
|
135
|
-
<v-col cols="12" class="mb-2" v-if="selectedRoute.
|
|
136
|
+
<v-col cols="12" class="mb-2" v-if="selectedRoute.timed === false">
|
|
137
|
+
Security Check Schedule:
|
|
138
|
+
<span class="font-weight-bold">Anytime (timing off)</span>
|
|
139
|
+
</v-col>
|
|
140
|
+
|
|
141
|
+
<v-col cols="12" class="mb-2" v-if="selectedRoute.timed !== false && selectedRoute.tolerance">
|
|
136
142
|
Tolerance:
|
|
137
143
|
<span class="font-weight-bold">
|
|
138
144
|
{{ selectedRoute.tolerance }} mins
|
|
@@ -149,7 +155,7 @@
|
|
|
149
155
|
<v-col
|
|
150
156
|
cols="12"
|
|
151
157
|
class="mb-2"
|
|
152
|
-
v-if="selectedRoute.schedules && selectedRoute.schedules.length"
|
|
158
|
+
v-if="selectedRoute.timed !== false && selectedRoute.schedules && selectedRoute.schedules.length"
|
|
153
159
|
>
|
|
154
160
|
<div class="mb-2">
|
|
155
161
|
Security Check Schedule
|
|
@@ -376,6 +382,7 @@ watchEffect(() => {
|
|
|
376
382
|
days: item.days,
|
|
377
383
|
startTimes: item.startTimes || [],
|
|
378
384
|
checkPoints: item.checkPoints || [],
|
|
385
|
+
timed: item.timed !== false,
|
|
379
386
|
// Display fields for the table
|
|
380
387
|
routeName: item.name,
|
|
381
388
|
numberOfCheckpoints: item.checkPointNumber,
|
|
@@ -392,6 +399,11 @@ watchEffect(() => {
|
|
|
392
399
|
}
|
|
393
400
|
});
|
|
394
401
|
|
|
402
|
+
// A new search starts on page 1 (from page 2 or later it could show nothing).
|
|
403
|
+
watch(searchInput, () => {
|
|
404
|
+
page.value = 1;
|
|
405
|
+
});
|
|
406
|
+
|
|
395
407
|
const dialogAdd = ref(false);
|
|
396
408
|
const dialogEdit = ref(false);
|
|
397
409
|
const dialogPreview = ref(false);
|
|
@@ -108,6 +108,7 @@ function skipped(row: NFCPatrolReportSummaryRow) {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
function statusClass(status: NFCPatrolReportSummaryStatus) {
|
|
111
|
+
if (status === "In progress") return "text-warning";
|
|
111
112
|
return status === "Completed" ? "text-success" : "text-error";
|
|
112
113
|
}
|
|
113
114
|
</script>
|
|
@@ -3,6 +3,8 @@ import type {
|
|
|
3
3
|
NFCPatrolReportFilters,
|
|
4
4
|
} from "../types/nfc-patrol-report";
|
|
5
5
|
import useNFCPatrolRoute from "./useNFCPatrolRoute";
|
|
6
|
+
import useNFCPatrolLog from "./useNFCPatrolLog";
|
|
7
|
+
import { collectAllPages } from "../utils/nfc-patrol-report";
|
|
6
8
|
|
|
7
9
|
function getTodayDate() {
|
|
8
10
|
const now = new Date();
|
|
@@ -13,6 +15,7 @@ function getTodayDate() {
|
|
|
13
15
|
}
|
|
14
16
|
export function useNFCPatrolReportFilters(site: string) {
|
|
15
17
|
const { getAll: getPatrolRoutes } = useNFCPatrolRoute();
|
|
18
|
+
const { getAll: getPatrolLogs } = useNFCPatrolLog();
|
|
16
19
|
|
|
17
20
|
const filters = reactive<NFCPatrolReportFilters>({
|
|
18
21
|
route: "",
|
|
@@ -34,17 +37,25 @@ export function useNFCPatrolReportFilters(site: string) {
|
|
|
34
37
|
// Deleted routes too, labelled, so their past patrols can still be
|
|
35
38
|
// reported on. An API from before route deletion refuses the parameter
|
|
36
39
|
// with a 400: ask again without it, and the list is what it was.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
// Every page: a site may have more than 100 routes (deleted ones count).
|
|
41
|
+
const allRoutes = (includeDeleted: boolean) =>
|
|
42
|
+
collectAllPages(
|
|
43
|
+
(page) =>
|
|
44
|
+
getPatrolRoutes({
|
|
45
|
+
site,
|
|
46
|
+
page,
|
|
47
|
+
limit: 100,
|
|
48
|
+
...(includeDeleted ? { includeDeleted: true } : {}),
|
|
49
|
+
}) as any,
|
|
50
|
+
1000,
|
|
51
|
+
);
|
|
52
|
+
const items: Array<Record<string, any>> = await allRoutes(true).catch(
|
|
53
|
+
(error: any) => {
|
|
54
|
+
const status = Number(error?.statusCode ?? error?.response?.status ?? 0);
|
|
55
|
+
if (status !== 400) throw error;
|
|
56
|
+
return allRoutes(false);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
48
59
|
const isDeleted = (route: Record<string, any>) => route.status === "Deleted";
|
|
49
60
|
// Routes still in use first, so the report opens on one of them.
|
|
50
61
|
const routes = [
|
|
@@ -54,6 +65,7 @@ export function useNFCPatrolReportFilters(site: string) {
|
|
|
54
65
|
title: isDeleted(route) ? `${route.name} (deleted)` : route.name,
|
|
55
66
|
value: route._id,
|
|
56
67
|
startTimes: route.startTimes ?? [],
|
|
68
|
+
timed: route.timed !== false,
|
|
57
69
|
}));
|
|
58
70
|
|
|
59
71
|
options.value.routes = routes;
|
|
@@ -62,22 +74,50 @@ export function useNFCPatrolReportFilters(site: string) {
|
|
|
62
74
|
filters.route = routes[0]?.value ?? "";
|
|
63
75
|
}
|
|
64
76
|
|
|
65
|
-
updateStartTimes();
|
|
77
|
+
await updateStartTimes();
|
|
66
78
|
} finally {
|
|
67
79
|
loading.value = false;
|
|
68
80
|
}
|
|
69
81
|
}
|
|
70
82
|
|
|
71
|
-
|
|
83
|
+
let startTimesRequest = 0;
|
|
84
|
+
async function updateStartTimes() {
|
|
72
85
|
const route = options.value.routes.find(({ value }) => value === filters.route);
|
|
73
|
-
|
|
86
|
+
const request = ++startTimesRequest;
|
|
87
|
+
|
|
88
|
+
// An untimed route has no start times: its rounds start whenever the guard
|
|
89
|
+
// starts them, so offer the start times of that day's rounds instead.
|
|
90
|
+
let times: string[] = route?.startTimes ?? [];
|
|
91
|
+
if (route && route.timed === false && filters.date) {
|
|
92
|
+
try {
|
|
93
|
+
const logs = await collectAllPages(
|
|
94
|
+
(page) =>
|
|
95
|
+
getPatrolLogs({
|
|
96
|
+
page,
|
|
97
|
+
limit: 100,
|
|
98
|
+
site,
|
|
99
|
+
date: filters.date,
|
|
100
|
+
routeId: route.value,
|
|
101
|
+
}) as any,
|
|
102
|
+
1000,
|
|
103
|
+
);
|
|
104
|
+
times = [...new Set(logs.map((log) => String(log.route?.startTime ?? "")))]
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.sort();
|
|
107
|
+
} catch {
|
|
108
|
+
times = [];
|
|
109
|
+
}
|
|
110
|
+
if (request !== startTimesRequest) return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
options.value.timeRanges = times;
|
|
74
114
|
|
|
75
115
|
if (!options.value.timeRanges.includes(filters.timeRange)) {
|
|
76
116
|
filters.timeRange = options.value.timeRanges[0] ?? "";
|
|
77
117
|
}
|
|
78
118
|
}
|
|
79
119
|
|
|
80
|
-
watch(() => filters.route, updateStartTimes);
|
|
120
|
+
watch(() => [filters.route, filters.date], () => void updateStartTimes());
|
|
81
121
|
|
|
82
122
|
return {
|
|
83
123
|
filters,
|
|
@@ -32,11 +32,12 @@ export default function useNFCPatrolRoute() {
|
|
|
32
32
|
function add(value: {
|
|
33
33
|
name: string;
|
|
34
34
|
checkPointNumber: number;
|
|
35
|
-
tolerance: string;
|
|
35
|
+
tolerance: string | number;
|
|
36
36
|
days: number[];
|
|
37
37
|
startTimes: string[];
|
|
38
38
|
checkPoints: Array<{ nfcTag_id: string }>;
|
|
39
39
|
site: string;
|
|
40
|
+
timed?: boolean;
|
|
40
41
|
}) {
|
|
41
42
|
return useNuxtApp().$api<Record<string, any>>("/api/nfc-patrol-route", {
|
|
42
43
|
method: "POST",
|
|
@@ -61,11 +62,12 @@ export default function useNFCPatrolRoute() {
|
|
|
61
62
|
value: {
|
|
62
63
|
name: string;
|
|
63
64
|
checkPointNumber: number;
|
|
64
|
-
tolerance: string;
|
|
65
|
+
tolerance: string | number;
|
|
65
66
|
days: number[];
|
|
66
67
|
startTimes: string[];
|
|
67
68
|
checkPoints: Array<{ nfcTag_id: string }>;
|
|
68
69
|
site: string;
|
|
70
|
+
timed?: boolean;
|
|
69
71
|
}
|
|
70
72
|
) {
|
|
71
73
|
return useNuxtApp().$api(`/api/nfc-patrol-route/id/${id}`, {
|
package/nuxt.config.ts
CHANGED
|
@@ -17,6 +17,10 @@ export default defineNuxtConfig({
|
|
|
17
17
|
...(process.env.API_CORE
|
|
18
18
|
? { "/api/storage/**": { proxy: `${process.env.API_CORE}/api/files/object/**` } }
|
|
19
19
|
: {}),
|
|
20
|
+
// Browser logs (utils/client-log.ts), forwarded by API-core to the Seven365 Logger.
|
|
21
|
+
...(process.env.API_CORE
|
|
22
|
+
? { "/api/client-logs": { proxy: `${process.env.API_CORE}/api/client-logs` } }
|
|
23
|
+
: {}),
|
|
20
24
|
},
|
|
21
25
|
build: {
|
|
22
26
|
transpile: ["vuetify"],
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.143.0",
|
|
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.",
|
package/plugins/API.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { isPermissionRefusal } from "../utils/permission-refusal";
|
|
2
2
|
import { accessSuspendedMessage, SIGNIN_NOTICE_COOKIE } from "../utils/access-suspended";
|
|
3
|
+
import { apiPath, clientLog } from "../utils/client-log";
|
|
4
|
+
|
|
5
|
+
// Expected answers, not failures: the session lapsed, no permission, nothing there.
|
|
6
|
+
const UNLOGGED_REFUSALS = new Set([401, 403, 404]);
|
|
3
7
|
|
|
4
8
|
export default defineNuxtPlugin((nuxtApp) => {
|
|
5
9
|
const { cookieConfig } = useRuntimeConfig().public;
|
|
@@ -33,7 +37,33 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
33
37
|
// Both refusal shapes count: API-core's 403 + PERMISSION_DENIED and core's
|
|
34
38
|
// 401 + "You do not have permission ..." (vehicle writes and every other
|
|
35
39
|
// `requireSiteReach` gate). See utils/permission-refusal.ts.
|
|
36
|
-
|
|
40
|
+
// Saves that went through, so a client's "I clicked save" can be checked.
|
|
41
|
+
onResponse({ request, options, response }) {
|
|
42
|
+
const method = String(options.method ?? "GET").toUpperCase();
|
|
43
|
+
if (method === "GET" || !response.ok) return;
|
|
44
|
+
clientLog("info", `Saved: ${method} ${apiPath(String(request))}`, { status: response.status });
|
|
45
|
+
},
|
|
46
|
+
// Offline or the API unreachable: the click did nothing for the client.
|
|
47
|
+
onRequestError({ request, options, error }) {
|
|
48
|
+
const method = String(options.method ?? "GET").toUpperCase();
|
|
49
|
+
clientLog("error", `Request failed: ${method} ${apiPath(String(request))}: ${error?.message ?? "network error"}`, {
|
|
50
|
+
method,
|
|
51
|
+
route: apiPath(String(request)),
|
|
52
|
+
status: 0,
|
|
53
|
+
expected: !navigator.onLine || undefined,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
onResponseError({ request, options, response }) {
|
|
57
|
+
const status = response?.status ?? 0;
|
|
58
|
+
if (!UNLOGGED_REFUSALS.has(status)) {
|
|
59
|
+
const method = String(options.method ?? "GET").toUpperCase();
|
|
60
|
+
const reason = (response as any)?._data?.message;
|
|
61
|
+
clientLog(status >= 500 ? "error" : "warn", `${method} ${apiPath(String(request))} answered ${status}${reason ? `: ${reason}` : ""}`, {
|
|
62
|
+
method,
|
|
63
|
+
route: apiPath(String(request)),
|
|
64
|
+
status,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
37
67
|
// Every membership suspended (2026-10-01): the server already ended the
|
|
38
68
|
// session. Sign out here and let the sign-in page say why, once.
|
|
39
69
|
const suspended = accessSuspendedMessage((response as any)?._data);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { clientLog, configureClientLog, flushClientLogs } from "../utils/client-log";
|
|
2
|
+
|
|
3
|
+
// Crashes anywhere on the page go to the Seven365 Logger. Failed and successful
|
|
4
|
+
// API calls are logged from plugins/API.ts, where every request passes.
|
|
5
|
+
export default defineNuxtPlugin((nuxtApp) => {
|
|
6
|
+
configureClientLog(String(useRuntimeConfig().public.APP ?? ""));
|
|
7
|
+
|
|
8
|
+
const describe = (error: unknown) => {
|
|
9
|
+
const e = error as { message?: string; stack?: string; name?: string } | undefined;
|
|
10
|
+
return { message: e?.message || String(error), stack: e?.stack, name: e?.name };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// An old tab asking for a file the last deploy replaced: the page needs a
|
|
14
|
+
// reload, the code is not broken. Logged, never ticketed.
|
|
15
|
+
const isStaleBuild = (message: string) =>
|
|
16
|
+
/Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module/i.test(message);
|
|
17
|
+
|
|
18
|
+
nuxtApp.hook("vue:error", (error, _instance, info) => {
|
|
19
|
+
const { message, stack } = describe(error);
|
|
20
|
+
clientLog("error", message, { stack, source: `vue:${info}`, expected: isStaleBuild(message) || undefined });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
nuxtApp.hook("app:error", (error) => {
|
|
24
|
+
const { message, stack } = describe(error);
|
|
25
|
+
clientLog("error", message, { stack, source: "app", expected: isStaleBuild(message) || undefined });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
window.addEventListener("error", (event) => {
|
|
29
|
+
if (!event.message) return;
|
|
30
|
+
clientLog("error", event.message, {
|
|
31
|
+
stack: (event.error as Error | undefined)?.stack,
|
|
32
|
+
source: `${event.filename}:${event.lineno}:${event.colno}`,
|
|
33
|
+
expected: isStaleBuild(event.message) || undefined,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
38
|
+
const { message, stack, name } = describe(event.reason);
|
|
39
|
+
// Failed $api calls are already logged with their status by plugins/API.ts.
|
|
40
|
+
if (name === "FetchError") return;
|
|
41
|
+
clientLog("error", message, { stack, source: "promise", expected: isStaleBuild(message) || undefined });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
document.addEventListener("visibilitychange", () => {
|
|
45
|
+
if (document.visibilityState === "hidden") void flushClientLogs();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type NFCPatrolReportTab = "patrol" | "daily" | "monthly";
|
|
2
2
|
|
|
3
|
-
export type NFCPatrolActivityStatus = "Completed" | "Skipped";
|
|
3
|
+
export type NFCPatrolActivityStatus = "Completed" | "Skipped" | "Pending";
|
|
4
4
|
|
|
5
5
|
export interface NFCPatrolReportFilters {
|
|
6
6
|
route: string;
|
|
@@ -17,6 +17,8 @@ export interface NFCPatrolReportRouteOption {
|
|
|
17
17
|
title: string;
|
|
18
18
|
value: string;
|
|
19
19
|
startTimes: string[];
|
|
20
|
+
/** false = route timing switched off (rounds start any time). */
|
|
21
|
+
timed?: boolean;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface NFCPatrolReportFilterOptions {
|
|
@@ -69,7 +71,7 @@ export interface NFCPatrolReport {
|
|
|
69
71
|
activities: NFCPatrolActivity[];
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
export type NFCPatrolReportSummaryStatus = "Completed" | "Incomplete";
|
|
74
|
+
export type NFCPatrolReportSummaryStatus = "Completed" | "Incomplete" | "In progress";
|
|
73
75
|
|
|
74
76
|
export interface NFCPatrolReportSummaryRow {
|
|
75
77
|
id: string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Browser logs for the Seven365 Logger. Entries are batched and sent to API-core
|
|
2
|
+
// (POST /api/client-logs), which files them under this app's own folder and adds
|
|
3
|
+
// the signed-in user from the session. Nothing is sent in development.
|
|
4
|
+
type Level = "info" | "warn" | "error";
|
|
5
|
+
|
|
6
|
+
type Entry = { level: Level; message: string; context: Record<string, unknown>; timestamp: string };
|
|
7
|
+
|
|
8
|
+
const queue: Entry[] = [];
|
|
9
|
+
const recent = new Map<string, number>();
|
|
10
|
+
const sessionId = Math.random().toString(36).slice(2, 12);
|
|
11
|
+
let app = "";
|
|
12
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
13
|
+
|
|
14
|
+
const BATCH = 20;
|
|
15
|
+
const FLUSH_MS = 5000;
|
|
16
|
+
const REPEAT_WINDOW_MS = 10000;
|
|
17
|
+
|
|
18
|
+
export function configureClientLog(appName: string) {
|
|
19
|
+
app = appName;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function apiPath(url: string) {
|
|
23
|
+
return String(url)
|
|
24
|
+
.split("?")[0]
|
|
25
|
+
.replace(/\b[0-9a-f]{24}\b/gi, ":id")
|
|
26
|
+
.replace(/\/\d+(?=\/|$)/g, "/:n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sid() {
|
|
30
|
+
const match = document.cookie.match(/(?:^|;\s*)sid=([^;]+)/);
|
|
31
|
+
return match ? decodeURIComponent(match[1]) : "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function clientLog(level: Level, message: string, context: Record<string, unknown> = {}) {
|
|
35
|
+
if (!import.meta.client || import.meta.dev || !app || !message) return;
|
|
36
|
+
|
|
37
|
+
// The same line from the same tab within 10s is one event (a re-rendering
|
|
38
|
+
// component can throw the same error dozens of times a second).
|
|
39
|
+
const key = `${level}|${message}`;
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if ((recent.get(key) ?? 0) > now - REPEAT_WINDOW_MS) return;
|
|
42
|
+
if (recent.size > 200) recent.clear();
|
|
43
|
+
recent.set(key, now);
|
|
44
|
+
|
|
45
|
+
queue.push({
|
|
46
|
+
level,
|
|
47
|
+
message: String(message).slice(0, 2000),
|
|
48
|
+
context: { ...context, page: location.pathname, sessionId },
|
|
49
|
+
timestamp: new Date().toISOString(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (queue.length >= BATCH) void flushClientLogs();
|
|
53
|
+
else if (!timer) timer = setTimeout(() => void flushClientLogs(), FLUSH_MS);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function flushClientLogs() {
|
|
57
|
+
if (timer) clearTimeout(timer);
|
|
58
|
+
timer = null;
|
|
59
|
+
const logs = queue.splice(0, 50);
|
|
60
|
+
if (!logs.length) return;
|
|
61
|
+
try {
|
|
62
|
+
await fetch("/api/client-logs", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
keepalive: true,
|
|
65
|
+
headers: { "Content-Type": "application/json", Authorization: sid() },
|
|
66
|
+
body: JSON.stringify({ app, logs }),
|
|
67
|
+
});
|
|
68
|
+
} catch {
|
|
69
|
+
// Logging must never break the page; a lost batch is acceptable.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -78,6 +78,30 @@ export async function collectAllPages(
|
|
|
78
78
|
return items;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** A round of a route whose timing is switched off: no window, never late. */
|
|
82
|
+
export function isUntimedLog(log: Record<string, any>) {
|
|
83
|
+
return log?.route?.timed === false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Was this checkpoint missed? A skip always is. A checkpoint still Pending
|
|
88
|
+
* once its timed round's window has closed is too: nothing on the server ever
|
|
89
|
+
* moves it off Pending, so a round the guard walked away from used to read
|
|
90
|
+
* "Completed, 0 missed". A round of an untimed route has no window and is
|
|
91
|
+
* never missed for time.
|
|
92
|
+
*/
|
|
93
|
+
export function isMissedCheckpoint(
|
|
94
|
+
log: Record<string, any>,
|
|
95
|
+
checkpoint: Record<string, any>,
|
|
96
|
+
now: Date = new Date(),
|
|
97
|
+
) {
|
|
98
|
+
if (checkpoint?.status === "Skipped") return true;
|
|
99
|
+
if (checkpoint?.status === "Completed") return false;
|
|
100
|
+
if (isUntimedLog(log)) return false;
|
|
101
|
+
const end = new Date(log?.endDateTime).getTime();
|
|
102
|
+
return Number.isFinite(end) && now.getTime() > end;
|
|
103
|
+
}
|
|
104
|
+
|
|
81
105
|
/** The month (0-11) a patrol log counts under; NaN for a date that is not one. */
|
|
82
106
|
export function monthIndexOf(log: Record<string, any>) {
|
|
83
107
|
return new Date(log?.date).getMonth();
|
|
@@ -91,6 +115,7 @@ export function monthIndexOf(log: Record<string, any>) {
|
|
|
91
115
|
*/
|
|
92
116
|
export function mapLogsToMonthlyRows(
|
|
93
117
|
logs: Record<string, any>[],
|
|
118
|
+
now: Date = new Date(),
|
|
94
119
|
): TPatrolMonthRow[] {
|
|
95
120
|
const rows: TPatrolMonthRow[] = MONTHS.map((month) => ({
|
|
96
121
|
month,
|
|
@@ -106,7 +131,7 @@ export function mapLogsToMonthlyRows(
|
|
|
106
131
|
|
|
107
132
|
for (const checkpoint of log?.checkPoints ?? []) {
|
|
108
133
|
if (checkpoint?.status === "Completed") rows[monthIndex].checked++;
|
|
109
|
-
else if (checkpoint
|
|
134
|
+
else if (isMissedCheckpoint(log, checkpoint, now)) rows[monthIndex].missed++;
|
|
110
135
|
}
|
|
111
136
|
}
|
|
112
137
|
|
|
@@ -168,15 +193,20 @@ export function mapLogToPatrolRun(
|
|
|
168
193
|
};
|
|
169
194
|
});
|
|
170
195
|
|
|
196
|
+
const untimed = isUntimedLog(log);
|
|
197
|
+
|
|
171
198
|
return {
|
|
172
199
|
routeSummary: {
|
|
173
200
|
routeName: log.route?.name,
|
|
174
|
-
tolerance: Number(log.route?.tolerance) || 0,
|
|
175
|
-
schedule
|
|
176
|
-
|
|
177
|
-
end:
|
|
178
|
-
|
|
179
|
-
|
|
201
|
+
tolerance: untimed ? 0 : Number(log.route?.tolerance) || 0,
|
|
202
|
+
// An untimed round has no schedule to compare against.
|
|
203
|
+
schedule: untimed
|
|
204
|
+
? { start: "Anytime", end: "-", totalTime: "-" }
|
|
205
|
+
: {
|
|
206
|
+
start: log.route?.startTime,
|
|
207
|
+
end: log.route?.endTime,
|
|
208
|
+
totalTime: diffMinutes(log.startDateTime, log.endDateTime),
|
|
209
|
+
},
|
|
180
210
|
actual: {
|
|
181
211
|
start: clockTime(actual.start, timezone),
|
|
182
212
|
end: clockTime(actual.end, timezone),
|
|
@@ -196,21 +226,28 @@ export function mapLogToSummaryRow(
|
|
|
196
226
|
log: Record<string, any>,
|
|
197
227
|
index: number,
|
|
198
228
|
siteTimezone?: string,
|
|
229
|
+
now: Date = new Date(),
|
|
199
230
|
): NFCPatrolReportSummaryRow {
|
|
200
231
|
const checkpoints: Record<string, any>[] = log.checkPoints ?? [];
|
|
201
232
|
const checked = checkpoints.filter((cp) => cp.status === "Completed").length;
|
|
202
|
-
const missed = checkpoints.filter((cp) => cp
|
|
233
|
+
const missed = checkpoints.filter((cp) => isMissedCheckpoint(log, cp, now)).length;
|
|
234
|
+
const allDone =
|
|
235
|
+
checkpoints.length > 0 &&
|
|
236
|
+
checkpoints.every((cp) => cp.status === "Completed" || cp.status === "Skipped");
|
|
237
|
+
const status = missed > 0 ? "Incomplete" : allDone ? "Completed" : "In progress";
|
|
203
238
|
|
|
204
239
|
return {
|
|
205
240
|
id: log._id,
|
|
206
241
|
date: log.date,
|
|
207
242
|
routeName: `${index + 1}. ${log.route?.name ?? "-"}`,
|
|
208
|
-
securityCheckSchedule: log
|
|
243
|
+
securityCheckSchedule: isUntimedLog(log)
|
|
244
|
+
? `Anytime (started ${log.route?.startTime ?? "-"})`
|
|
245
|
+
: log.route?.startTime ?? "-",
|
|
209
246
|
guardName: log.sign?.guardName,
|
|
210
247
|
guardSignatureFileId: log.sign?.guardSignatureFileId,
|
|
211
248
|
checked,
|
|
212
249
|
missed,
|
|
213
|
-
status
|
|
250
|
+
status,
|
|
214
251
|
detail: mapLogToPatrolRun(log, siteTimezone),
|
|
215
252
|
};
|
|
216
253
|
}
|
|
@@ -71,3 +71,23 @@ export function formatRouteDays(days: number[] = []) {
|
|
|
71
71
|
}
|
|
72
72
|
return parts.join(", ");
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The route form's "Select all" for days: every day when not all are
|
|
77
|
+
* selected yet, none when they all are.
|
|
78
|
+
*/
|
|
79
|
+
export function allDaysToggled(selected: number[], allDays: number[]): number[] {
|
|
80
|
+
const everyDay = allDays.every((day) => selected.includes(day));
|
|
81
|
+
return everyDay ? [] : [...allDays];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Why a checkpoint count is not usable, or null when it is. Any whole number
|
|
86
|
+
* from 1 up: a route takes as many NFC tags as the site has (no cap).
|
|
87
|
+
*/
|
|
88
|
+
export function checkpointCountError(value: unknown): string | null {
|
|
89
|
+
const count = Number(value);
|
|
90
|
+
if (value === "" || value === null || value === undefined) return "This field is required";
|
|
91
|
+
if (!Number.isInteger(count) || count < 1) return "Enter a whole number of 1 or more";
|
|
92
|
+
return null;
|
|
93
|
+
}
|