@7365admin1/layer-common 4.97.1-staging.586 → 4.97.1-staging.588
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/AddPassKeyToVisitor.vue +332 -54
- package/components/FileInput.vue +4 -0
- package/components/MemberInformation.vue +580 -281
- package/components/Nfc/NFCPatrolRouteForm.vue +81 -13
- package/components/Nfc/NFCPatrolRouteMain.vue +17 -5
- package/components/Nfc/PatrolReport/SummaryReportTable.vue +1 -0
- package/components/NricScanDialog.vue +512 -0
- package/components/PassInformation.vue +11 -2
- package/components/VisitorForm.vue +1793 -642
- package/components/VisitorFormSelection.vue +51 -18
- package/components/VisitorManagement.vue +4507 -3518
- package/components/VisitorPassKeyQRScanner.vue +1 -1
- package/components/VisitorSocketPopUp.vue +72 -23
- package/composables/useNFCPatrolReportFilters.ts +55 -15
- package/composables/useNFCPatrolRoute.ts +4 -2
- package/composables/usePeople.ts +54 -0
- package/composables/useVisitor.ts +4 -2
- package/composables/useVisitorSocket.ts +19 -0
- package/package.json +2 -1
- package/types/nfc-patrol-report.ts +4 -2
- package/utils/nfc-patrol-report.ts +47 -10
- package/utils/nfc-patrol-schedule.ts +20 -0
- package/utils/nric-card.ts +278 -0
- package/utils/visitor-people.ts +262 -0
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<v-card class="screen-modal qr-scanner">
|
|
7
7
|
<div class="qr-scanner__head">
|
|
8
8
|
<v-card-title>Scan QR Code</v-card-title>
|
|
9
|
-
<
|
|
9
|
+
<v-btn icon="mdi-close" variant="text" aria-label="Close" @click="closeDialog()" />
|
|
10
10
|
</div>
|
|
11
11
|
|
|
12
12
|
<div class="screen-modal__body qr-scanner__body">
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
fires, what it links to, or how long it stays; only how it is drawn.
|
|
42
42
|
-->
|
|
43
43
|
<v-snackbar
|
|
44
|
-
v-if="
|
|
44
|
+
v-if="enableUnregisteredSnackbar && canViewVisitor"
|
|
45
45
|
v-model="snackBarMessage.modal"
|
|
46
46
|
:timeout="-1"
|
|
47
47
|
location="bottom center"
|
|
@@ -75,8 +75,10 @@
|
|
|
75
75
|
</div>
|
|
76
76
|
|
|
77
77
|
<div class="anpr-toast__actions">
|
|
78
|
+
<!-- "Register" for a user who can register (Update right), as the
|
|
79
|
+
security mobile app; otherwise only through to the list. -->
|
|
78
80
|
<AppButton variant="ghost" @click="goToUnregistered">
|
|
79
|
-
Go to Unregistered
|
|
81
|
+
{{ canUpdateVisitor ? "Register" : "Go to Unregistered" }}
|
|
80
82
|
</AppButton>
|
|
81
83
|
</div>
|
|
82
84
|
</v-snackbar>
|
|
@@ -131,6 +133,11 @@ const props = defineProps({
|
|
|
131
133
|
type: Boolean,
|
|
132
134
|
default: false,
|
|
133
135
|
},
|
|
136
|
+
/** The visitor Update right: the notice's button reads "Register". */
|
|
137
|
+
canUpdateVisitor: {
|
|
138
|
+
type: Boolean,
|
|
139
|
+
default: false,
|
|
140
|
+
},
|
|
134
141
|
});
|
|
135
142
|
|
|
136
143
|
const config = useRuntimeConfig();
|
|
@@ -141,8 +148,36 @@ const { tab } = useVisitor();
|
|
|
141
148
|
const { enableUnregistered, ensureANPRSettings } = useANPRSettings(
|
|
142
149
|
() => props.siteId
|
|
143
150
|
);
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
// The security mobile app's rule (isecure365 `useUnregisteredTab`): the
|
|
152
|
+
// site's Unregistered switch AND an ANPR camera facing an entrance (entry /
|
|
153
|
+
// both). With the switch alone the notice could appear for a site whose
|
|
154
|
+
// cameras never register anyone arriving.
|
|
155
|
+
const { getAllSiteCameras } = useSiteSettings();
|
|
156
|
+
const hasEntryFacingAnprCamera = ref(false);
|
|
157
|
+
|
|
158
|
+
async function loadEntryFacingAnprCamera(siteId: string) {
|
|
159
|
+
hasEntryFacingAnprCamera.value = false;
|
|
160
|
+
if (!siteId) return;
|
|
161
|
+
try {
|
|
162
|
+
const res = await getAllSiteCameras({ site: siteId, type: "anpr", page: 1, limit: 100 });
|
|
163
|
+
if (siteId !== props.siteId) return;
|
|
164
|
+
const items = Array.isArray(res?.items)
|
|
165
|
+
? res.items
|
|
166
|
+
: Array.isArray(res?.data?.items)
|
|
167
|
+
? res.data.items
|
|
168
|
+
: [];
|
|
169
|
+
hasEntryFacingAnprCamera.value = items.some(
|
|
170
|
+
(camera: any) => camera?.direction === "entry" || camera?.direction === "both"
|
|
171
|
+
);
|
|
172
|
+
} catch {
|
|
173
|
+
hasEntryFacingAnprCamera.value = false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const enableUnregisteredSnackbar = computed(
|
|
178
|
+
() => enableUnregistered.value && hasEntryFacingAnprCamera.value
|
|
179
|
+
);
|
|
180
|
+
const { socketUnregisteredVisitorTrigger, visitorSocketData, registerVisitorFormData } =
|
|
146
181
|
useVisitorSocket();
|
|
147
182
|
|
|
148
183
|
const APP_NAME = publicConfig.APP || "";
|
|
@@ -260,10 +295,10 @@ const showPlate = (plateNumber: string) => {
|
|
|
260
295
|
}
|
|
261
296
|
|
|
262
297
|
snackBarMessage.plateNumber = plateNumber;
|
|
298
|
+
// No timeout (security mobile app, 2026-10): it stays on every screen until
|
|
299
|
+
// it is closed or its button is used. A newer plate replaces it. (The
|
|
300
|
+
// window below is no longer started; its cancels are harmless.)
|
|
263
301
|
snackBarMessage.modal = true;
|
|
264
|
-
// Restart the 20s window from zero. A replacement plate gets a full window
|
|
265
|
-
// of its own; without this it inherited whatever was left of the one before.
|
|
266
|
-
plateToastWindow.restart();
|
|
267
302
|
};
|
|
268
303
|
|
|
269
304
|
const connectSocket = () => {
|
|
@@ -284,17 +319,27 @@ const connectSocket = () => {
|
|
|
284
319
|
|
|
285
320
|
socket.value.on("anpr-messages", (data: TVisitorSocketData) => {
|
|
286
321
|
const plateNumber = data?.plateNumber?.trim() || "";
|
|
287
|
-
const
|
|
288
|
-
const isPlateNumberChanging =
|
|
289
|
-
Boolean(lastPlateNumber.value) && lastPlateNumber.value !== plateNumber;
|
|
322
|
+
const recordId = String(data?._id ?? "").trim();
|
|
290
323
|
|
|
291
|
-
|
|
324
|
+
// Every message still reaches the visitor list (its reload).
|
|
292
325
|
visitorSocketData.value = null;
|
|
293
326
|
visitorSocketData.value = data;
|
|
294
327
|
newVisitor.value = data;
|
|
295
328
|
|
|
296
|
-
if (
|
|
297
|
-
|
|
329
|
+
if (plateNumber) {
|
|
330
|
+
// An arrival. Only one with both the record id and the plate is kept for
|
|
331
|
+
// Register, and it drops any outstanding trigger; a read with no record
|
|
332
|
+
// has nothing to register and raises no notice (security mobile app).
|
|
333
|
+
// Nothing else on the feed touches the stored arrival.
|
|
334
|
+
if (recordId) {
|
|
335
|
+
registerVisitorFormData.value = {
|
|
336
|
+
_id: recordId,
|
|
337
|
+
plateNumber,
|
|
338
|
+
site: data?.site,
|
|
339
|
+
};
|
|
340
|
+
socketUnregisteredVisitorTrigger.value = false;
|
|
341
|
+
showPlate(plateNumber);
|
|
342
|
+
}
|
|
298
343
|
lastPlateNumber.value = plateNumber;
|
|
299
344
|
return;
|
|
300
345
|
}
|
|
@@ -354,24 +399,27 @@ const goToUnregistered = async () => {
|
|
|
354
399
|
});
|
|
355
400
|
};
|
|
356
401
|
|
|
402
|
+
// A site switch, or leaving the signed-in layout (logout): the open notice and
|
|
403
|
+
// the stored arrival belong to the old site, so neither survives it.
|
|
404
|
+
const clearPlateNotice = () => {
|
|
405
|
+
snackBarMessage.modal = false;
|
|
406
|
+
registerVisitorFormData.value = null;
|
|
407
|
+
socketUnregisteredVisitorTrigger.value = false;
|
|
408
|
+
};
|
|
409
|
+
|
|
357
410
|
watch(
|
|
358
411
|
() => props.siteId,
|
|
359
|
-
() => {
|
|
412
|
+
(siteId, previous) => {
|
|
413
|
+
if (previous !== undefined && siteId !== previous) clearPlateNotice();
|
|
360
414
|
disconnectSocket();
|
|
361
415
|
connectSocket();
|
|
416
|
+
void loadEntryFacingAnprCamera(siteId);
|
|
362
417
|
},
|
|
363
418
|
{ immediate: true, deep: true }
|
|
364
419
|
);
|
|
365
420
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
() => {
|
|
369
|
-
if (isUnregisteredVisitorRoute.value) {
|
|
370
|
-
snackBarMessage.modal = false;
|
|
371
|
-
}
|
|
372
|
-
},
|
|
373
|
-
{ immediate: true }
|
|
374
|
-
);
|
|
421
|
+
// The notice now also shows on the Unregistered tab (security mobile app):
|
|
422
|
+
// its button opens Register for the record there.
|
|
375
423
|
|
|
376
424
|
watch(enableUnregisteredSnackbar, (enabled) => {
|
|
377
425
|
if (!enabled) {
|
|
@@ -402,6 +450,7 @@ watch(
|
|
|
402
450
|
);
|
|
403
451
|
|
|
404
452
|
onUnmounted(() => {
|
|
453
|
+
clearPlateNotice();
|
|
405
454
|
plateToastWindow.cancel();
|
|
406
455
|
if (typeof document !== "undefined") {
|
|
407
456
|
document.removeEventListener("keydown", onEscape);
|
|
@@ -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/composables/usePeople.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
VISITOR_PERSON_TYPES,
|
|
3
|
+
recordIdsOf,
|
|
4
|
+
visitorPeopleResults,
|
|
5
|
+
type VisitorPerson,
|
|
6
|
+
} from "../utils/visitor-people";
|
|
7
|
+
|
|
1
8
|
export default function () {
|
|
2
9
|
async function getAll({
|
|
3
10
|
page = 1,
|
|
@@ -66,6 +73,51 @@ export default function () {
|
|
|
66
73
|
});
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The visitor form's autofill lookup across EVERY visitor type, as the
|
|
78
|
+
* security mobile app (isecure365 `findAnyVisitorType`): one request with
|
|
79
|
+
* `type[]=guest&type[]=contractor&type[]=delivery`, the type being registered
|
|
80
|
+
* listed first, and one person on file under several types merged into one
|
|
81
|
+
* result. `walk-in` is looked up as `guest`.
|
|
82
|
+
*/
|
|
83
|
+
async function findVisitorPeopleAnyType(
|
|
84
|
+
by: "nric" | "contact" | "plateNumber",
|
|
85
|
+
term: string,
|
|
86
|
+
site?: string,
|
|
87
|
+
preferType?: string
|
|
88
|
+
): Promise<VisitorPerson[]> {
|
|
89
|
+
const res = await $fetch<any>(`/api/people`, {
|
|
90
|
+
method: "GET",
|
|
91
|
+
query: {
|
|
92
|
+
[by]: term,
|
|
93
|
+
...(site ? { site } : {}),
|
|
94
|
+
"type[]": [...VISITOR_PERSON_TYPES],
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
const items: VisitorPerson[] = Array.isArray(res)
|
|
98
|
+
? res
|
|
99
|
+
: Array.isArray(res?.items)
|
|
100
|
+
? res.items
|
|
101
|
+
: Array.isArray(res?.data?.items)
|
|
102
|
+
? res.data.items
|
|
103
|
+
: Array.isArray(res?.data)
|
|
104
|
+
? res.data
|
|
105
|
+
: [];
|
|
106
|
+
const type = preferType === "walk-in" ? "guest" : preferType;
|
|
107
|
+
return visitorPeopleResults(items, type);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Deletes the People records behind one autofill result (a merged result is
|
|
112
|
+
* several): `PUT /api/people/:id`, a soft delete. For a person saved with a
|
|
113
|
+
* wrong NRIC; past visits are not touched.
|
|
114
|
+
*/
|
|
115
|
+
async function deletePersonRecords(person: VisitorPerson) {
|
|
116
|
+
for (const id of recordIdsOf(person)) {
|
|
117
|
+
await deleteById(id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
69
121
|
async function findPersonByContact(
|
|
70
122
|
contact: string
|
|
71
123
|
): Promise<null | Partial<TPeople>> {
|
|
@@ -164,6 +216,8 @@ export default function () {
|
|
|
164
216
|
findPersonByNRICMultipleResult,
|
|
165
217
|
findPersonByPhoneNumberMultipleResult,
|
|
166
218
|
findPersonByPlateNumberMultipleResult,
|
|
219
|
+
findVisitorPeopleAnyType,
|
|
220
|
+
deletePersonRecords,
|
|
167
221
|
findPersonByContact,
|
|
168
222
|
getPeopleByUnit,
|
|
169
223
|
getOccupiedStructure,
|
|
@@ -4,11 +4,13 @@ export default function () {
|
|
|
4
4
|
value: TVisitorType;
|
|
5
5
|
};
|
|
6
6
|
|
|
7
|
+
// The Add type picker, in iSecure's order and words (`VISITOR_FORM_TYPES` /
|
|
8
|
+
// `VISITOR_FORM_TITLES`). The values are the stored types - never rename.
|
|
7
9
|
const visitorSelection: TVisitorSelection[] = [
|
|
8
|
-
{ label: "
|
|
10
|
+
{ label: "Guest", value: "guest" },
|
|
9
11
|
{ label: "Contractor", value: "contractor" },
|
|
10
|
-
{ label: "Walk-In", value: "walk-in" },
|
|
11
12
|
{ label: "Delivery", value: "delivery" },
|
|
13
|
+
{ label: "Walk-In", value: "walk-in" },
|
|
12
14
|
{ label: "Pick-Up", value: "pick-up" },
|
|
13
15
|
{ label: "Drop-Off", value: "drop-off" },
|
|
14
16
|
];
|
|
@@ -64,7 +64,25 @@ export const plateToast = (
|
|
|
64
64
|
};
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The last vehicle ARRIVAL the ANPR camera read, kept so the plate notice's
|
|
69
|
+
* Register / "Go to Unregistered" opens the Register flow for THAT record.
|
|
70
|
+
* As the security mobile app (isecure365 `anpr-unregistered.ts`): only a read
|
|
71
|
+
* with both the record id and the plate is kept, and nothing else on the feed
|
|
72
|
+
* (a camera alert, a reload) replaces it.
|
|
73
|
+
*/
|
|
74
|
+
export type TRegisterVisitorFormData = {
|
|
75
|
+
/** The unregistered record the camera created - the one the form updates. */
|
|
76
|
+
_id: string;
|
|
77
|
+
plateNumber: string;
|
|
78
|
+
site?: string;
|
|
79
|
+
};
|
|
80
|
+
|
|
67
81
|
export const useVisitorSocket = () => {
|
|
82
|
+
const registerVisitorFormData = useState(
|
|
83
|
+
"registerVisitorFormData",
|
|
84
|
+
(): TRegisterVisitorFormData | null => null,
|
|
85
|
+
);
|
|
68
86
|
const socketUnregisteredVisitorTrigger = useState(
|
|
69
87
|
"socketUnregisteredVisitorTrigger",
|
|
70
88
|
() => false,
|
|
@@ -76,6 +94,7 @@ export const useVisitorSocket = () => {
|
|
|
76
94
|
|
|
77
95
|
|
|
78
96
|
return {
|
|
97
|
+
registerVisitorFormData,
|
|
79
98
|
socketUnregisteredVisitorTrigger,
|
|
80
99
|
visitorSocketData,
|
|
81
100
|
};
|
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.97.1-staging.
|
|
5
|
+
"version": "4.97.1-staging.588",
|
|
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.",
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"sass": "^1.80.6",
|
|
70
70
|
"sip.js": "0.21.2",
|
|
71
71
|
"socket.io-client": "^4.8.3",
|
|
72
|
+
"tesseract.js": "^5.1.1",
|
|
72
73
|
"vue-draggable-next": "^2.3.0",
|
|
73
74
|
"vue3-signature": "^0.2.4",
|
|
74
75
|
"vuetify-pro-tiptap": "^2.8.2",
|
|
@@ -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;
|
|
@@ -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
|
+
}
|