@7365admin1/layer-common 4.148.0 → 4.148.2
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 +30 -4
- package/components/AnprCameraBadge.vue +15 -2
- package/components/CameraForm.vue +73 -17
- package/components/CameraMain.vue +12 -3
- package/components/Snackbar.vue +36 -0
- package/components/TableMain.vue +12 -1
- package/components/VehicleAddSelection.vue +11 -4
- package/components/VehicleManagement.vue +83 -30
- package/components/VisitorDataFromScannedQRCodeForm.vue +6 -1
- package/components/VisitorForm.vue +47 -17
- package/components/VisitorManagement.vue +145 -8
- package/composables/useVisitorCameraChoices.ts +61 -0
- package/package.json +1 -1
- package/types/camera.d.ts +2 -0
- package/utils/anpr-direction.ts +7 -6
- package/utils/vehicle-actions.ts +23 -5
- package/utils/visitor-barrier.ts +42 -0
- package/utils/visitor-camera.ts +80 -0
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<AppButton v-if="qrStickersOn" icon="mdi-qrcode" variant="ghost" @click="openStickerDialog()">
|
|
18
18
|
Print QR stickers
|
|
19
19
|
</AppButton>
|
|
20
|
-
<AppButton v-if="canCreateVehicle" icon="mdi-plus" @click="handleAddVehicleClick">
|
|
20
|
+
<AppButton v-if="canCreateVehicle || canAddBlocklist" icon="mdi-plus" @click="handleAddVehicleClick">
|
|
21
21
|
Add Vehicle
|
|
22
22
|
</AppButton>
|
|
23
23
|
</template>
|
|
@@ -103,26 +103,26 @@
|
|
|
103
103
|
be drawn.
|
|
104
104
|
-->
|
|
105
105
|
<template #item.action="{ item }">
|
|
106
|
-
<v-menu v-if="rowHasVehicleActions(item, vehiclePermissions) || qrStickersOn" location="bottom end">
|
|
106
|
+
<v-menu v-if="rowHasVehicleActions(item, vehiclePermissions) || qrStickersOn || rowPlates(item).some(qrActionsOn)" location="bottom end">
|
|
107
107
|
<template #activator="{ props: menuProps }">
|
|
108
108
|
<AppButton v-bind="menuProps" variant="row" icon="mdi-dots-vertical" aria-label="Vehicle actions"
|
|
109
109
|
@click.stop />
|
|
110
110
|
</template>
|
|
111
111
|
<v-list density="compact">
|
|
112
112
|
<template v-for="plate in rowPlates(item)" :key="plate._id ?? plate.plateNumber">
|
|
113
|
-
<template v-if="vehiclePlateActions(plate, vehiclePermissions).length || qrStickersOn">
|
|
113
|
+
<template v-if="vehiclePlateActions(plate, vehiclePermissions).length || qrStickersOn || qrActionsOn(plate)">
|
|
114
114
|
<v-list-subheader v-if="rowPlates(item).length > 1">
|
|
115
115
|
{{ plate.plateNumber }}
|
|
116
116
|
</v-list-subheader>
|
|
117
117
|
<v-list-item v-if="qrStickersOn && plate.hasQrSticker" title="Print sticker again" prepend-icon="mdi-printer"
|
|
118
118
|
data-test="vehicle-qr-print-again" @click="openStickerDialog([String(plate._id)])" />
|
|
119
|
-
<v-list-item v-if="
|
|
119
|
+
<v-list-item v-if="qrActionsOn(plate)" title="Re-issue sticker (lost or stolen)" prepend-icon="mdi-qrcode-edit"
|
|
120
120
|
@click="askReissue(plate as TPlateNumber)" />
|
|
121
|
-
<v-list-item v-if="
|
|
121
|
+
<v-list-item v-if="qrActionsOn(plate)" title="Undo re-issue" prepend-icon="mdi-undo"
|
|
122
122
|
data-test="vehicle-qr-undo" @click="askUndo(plate as TPlateNumber)" />
|
|
123
|
-
<v-list-item v-if="
|
|
123
|
+
<v-list-item v-if="qrActionsOn(plate)" title="Link QR sticker" prepend-icon="mdi-link-variant"
|
|
124
124
|
@click="askLink(plate as TPlateNumber)" />
|
|
125
|
-
<v-list-item v-if="
|
|
125
|
+
<v-list-item v-if="qrActionsOn(plate)" title="Remove QR sticker" prepend-icon="mdi-link-variant-off"
|
|
126
126
|
@click="askUnlink(plate as TPlateNumber)" />
|
|
127
127
|
<v-list-item v-for="action in vehiclePlateActions(plate, vehiclePermissions)" :key="action.key"
|
|
128
128
|
:title="action.title" :prepend-icon="action.icon" :base-color="action.color"
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
<!-- <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" /> -->
|
|
138
138
|
|
|
139
139
|
<v-dialog v-model="dialog.showSelection" width="450" persistent>
|
|
140
|
-
<VehicleAddSelection :can-manage-blocklist="canManageBlocklist" @cancel="dialog.showSelection = false" @select="handleSelectVehicleStatus" />
|
|
140
|
+
<VehicleAddSelection :can-manage-blocklist="canManageBlocklist" :can-add-whitelist="canCreateVehicle" @cancel="dialog.showSelection = false" @select="handleSelectVehicleStatus" />
|
|
141
141
|
</v-dialog>
|
|
142
142
|
|
|
143
143
|
<!-- Many whitelist vehicles at once. Wider than the one-vehicle form: it
|
|
@@ -232,7 +232,7 @@
|
|
|
232
232
|
<AnprCameraBadge :cameras="(plateNumberItem as any).anprCameras ?? []" :plate-number="value"
|
|
233
233
|
:plate-id="(plateNumberItem as any)._id ?? ''" :status="(plateNumberItem as any).status ?? ''"
|
|
234
234
|
:type="(plateNumberItem as any).type ?? ''" :end="(plateNumberItem as any).end ?? null"
|
|
235
|
-
:active-counts="activeCameraCounts"
|
|
235
|
+
:active-counts="activeCameraCounts" :can-sync="syncAllowed(plateNumberItem)"
|
|
236
236
|
@synced="handleSynced" />
|
|
237
237
|
</span>
|
|
238
238
|
</template>
|
|
@@ -244,20 +244,20 @@
|
|
|
244
244
|
v-tooltip:top="'History'" @click="openHistory(plateNumberItem as TPlateNumber)" />
|
|
245
245
|
</template>
|
|
246
246
|
<template #item.action="{ item: plateNumberItem }">
|
|
247
|
-
<v-menu v-if="vehiclePlateActions(plateNumberItem, vehiclePermissions).length || qrStickersOn">
|
|
247
|
+
<v-menu v-if="vehiclePlateActions(plateNumberItem, vehiclePermissions).length || qrStickersOn || qrActionsOn(plateNumberItem)">
|
|
248
248
|
<template #activator="{ props: menuProps }">
|
|
249
249
|
<v-btn icon="mdi-dots-vertical" v-bind="menuProps" flat size="x-small" />
|
|
250
250
|
</template>
|
|
251
251
|
<v-list density="compact">
|
|
252
252
|
<v-list-item v-if="qrStickersOn && (plateNumberItem as any).hasQrSticker" title="Print sticker again" prepend-icon="mdi-printer"
|
|
253
253
|
data-test="vehicle-qr-print-again" @click="openStickerDialog([String((plateNumberItem as any)._id)])" />
|
|
254
|
-
<v-list-item v-if="
|
|
254
|
+
<v-list-item v-if="qrActionsOn(plateNumberItem)" title="Re-issue sticker (lost or stolen)" prepend-icon="mdi-qrcode-edit"
|
|
255
255
|
@click="askReissue((plateNumberItem as any) as TPlateNumber)" />
|
|
256
|
-
<v-list-item v-if="
|
|
256
|
+
<v-list-item v-if="qrActionsOn(plateNumberItem)" title="Undo re-issue" prepend-icon="mdi-undo"
|
|
257
257
|
data-test="vehicle-qr-undo" @click="askUndo((plateNumberItem as any) as TPlateNumber)" />
|
|
258
|
-
<v-list-item v-if="
|
|
258
|
+
<v-list-item v-if="qrActionsOn(plateNumberItem)" title="Link QR sticker" prepend-icon="mdi-link-variant"
|
|
259
259
|
@click="askLink(plateNumberItem as TPlateNumber)" />
|
|
260
|
-
<v-list-item v-if="
|
|
260
|
+
<v-list-item v-if="qrActionsOn(plateNumberItem)" title="Remove QR sticker" prepend-icon="mdi-link-variant-off"
|
|
261
261
|
@click="askUnlink(plateNumberItem as TPlateNumber)" />
|
|
262
262
|
<v-list-item
|
|
263
263
|
v-for="action in vehiclePlateActions(plateNumberItem, vehiclePermissions)"
|
|
@@ -296,7 +296,7 @@
|
|
|
296
296
|
when the ANPR revoke could not be confirmed - the dialog stays open on
|
|
297
297
|
that path so the operator can read it and retry.
|
|
298
298
|
-->
|
|
299
|
-
<DialogDeleteConfirmation :loading="deletingVehicle" :message="deleteError" :no-permission="revoked
|
|
299
|
+
<DialogDeleteConfirmation :loading="deletingVehicle" :message="deleteError" :no-permission="revoked[rightFor('delete')]"
|
|
300
300
|
:prompt-title="`Remove vehicle number ${selectedPlateNumberObject?.plateNumber} from this record? This revokes its ANPR access - it will no longer open the barrier. The resident and any other vehicle numbers on this record are not affected.`"
|
|
301
301
|
@delete="submitDelete" @close="closeDeleteDialog" />
|
|
302
302
|
</v-dialog>
|
|
@@ -314,19 +314,19 @@
|
|
|
314
314
|
<!-- Like Delete, Restore says what it does at the barrier: a whitelist
|
|
315
315
|
plate goes back on the allowed list and opens it again. -->
|
|
316
316
|
<v-dialog v-model="dialog.restoreVehicle" persistent width="540">
|
|
317
|
-
<DialogReusablePrompt :loading="restoringVehicle" :message="actionError" message-error :no-permission="revoked
|
|
317
|
+
<DialogReusablePrompt :loading="restoringVehicle" :message="actionError" message-error :no-permission="revoked[restoreRight()]"
|
|
318
318
|
:prompt-title="restorePrompt"
|
|
319
319
|
@proceed="submitRestore" @close="closeActionDialog('restoreVehicle')" />
|
|
320
320
|
</v-dialog>
|
|
321
321
|
<!-- Block / Unblock say what happens on the cameras, because that is the
|
|
322
322
|
part the operator cannot see from here. -->
|
|
323
323
|
<v-dialog v-model="dialog.blockVehicle" persistent width="540">
|
|
324
|
-
<DialogReusablePrompt :loading="blockingVehicle" :message="actionError" message-error :no-permission="revoked
|
|
324
|
+
<DialogReusablePrompt :loading="blockingVehicle" :message="actionError" message-error :no-permission="revoked[rightFor('update', true)]"
|
|
325
325
|
:prompt-title="`Block vehicle ${selectedPlateNumberObject?.plateNumber}? It is added to every ANPR camera's blocklist and taken off the allowed list. Its status becomes Blocked and its category Block Listed.`"
|
|
326
326
|
@proceed="submitBlock" @close="closeActionDialog('blockVehicle')" />
|
|
327
327
|
</v-dialog>
|
|
328
328
|
<v-dialog v-model="dialog.unblockVehicle" persistent width="540">
|
|
329
|
-
<DialogReusablePrompt :loading="unblockingVehicle" :message="actionError" message-error :no-permission="revoked
|
|
329
|
+
<DialogReusablePrompt :loading="unblockingVehicle" :message="actionError" message-error :no-permission="revoked[rightFor('update', true)]"
|
|
330
330
|
:prompt-title="`Unblock vehicle ${selectedPlateNumberObject?.plateNumber}? It is taken off every ANPR camera's blocklist. It is NOT whitelisted - the record stays with status Unblocked.`"
|
|
331
331
|
@proceed="submitUnblock" @close="closeActionDialog('unblockVehicle')" />
|
|
332
332
|
</v-dialog>
|
|
@@ -590,6 +590,14 @@ const vehicleTabs = computed<{ name: string; value: TVehicleTab }[]>(() => [
|
|
|
590
590
|
])
|
|
591
591
|
const activeTab = ref<TVehicleTab>("residents");
|
|
592
592
|
|
|
593
|
+
/**
|
|
594
|
+
* Adding to the Block Listed tab follows Manage Blocklist alone (owner,
|
|
595
|
+
* 2026-10-23), not Add Vehicle: a role with only that box still gets the Add
|
|
596
|
+
* Vehicle button, offering the Blocklist form only. Apps that do not pass the
|
|
597
|
+
* flag (undefined) keep today's rule - the button follows Add Vehicle.
|
|
598
|
+
*/
|
|
599
|
+
const canAddBlocklist = computed(() => props.canManageBlocklist === true);
|
|
600
|
+
|
|
593
601
|
|
|
594
602
|
|
|
595
603
|
const { formatCamelCaseToWords, formatDate, debounce, maskNRIC } = useUtils();
|
|
@@ -605,6 +613,28 @@ const qrStickerFilterItems = [
|
|
|
605
613
|
{ title: "Without sticker", value: "without" },
|
|
606
614
|
];
|
|
607
615
|
const qrStickersOn = computed(() => qrEnabled.value && props.canUpdateVehicle);
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Re-issue / Undo / Link / Remove on one plate change that vehicle, so on a
|
|
619
|
+
* Block Listed plate they follow Manage Blocklist (owner, 2026-10-23) when the
|
|
620
|
+
* host passes it; any other plate follows Update Vehicle. Printing stays with
|
|
621
|
+
* `qrStickersOn`.
|
|
622
|
+
*/
|
|
623
|
+
/** Sync on a Block Listed plate follows Manage Blocklist when the host passes it. */
|
|
624
|
+
function syncAllowed(plate: any): boolean {
|
|
625
|
+
if (props.canManageBlocklist !== undefined && plate?.type === "blocklist") {
|
|
626
|
+
return !!vehiclePermissions.value.canManageBlocklist;
|
|
627
|
+
}
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function qrActionsOn(plate: any): boolean {
|
|
632
|
+
if (!qrEnabled.value) return false;
|
|
633
|
+
if (props.canManageBlocklist !== undefined && plate?.type === "blocklist") {
|
|
634
|
+
return !!vehiclePermissions.value.canManageBlocklist;
|
|
635
|
+
}
|
|
636
|
+
return props.canUpdateVehicle;
|
|
637
|
+
}
|
|
608
638
|
const qrVehicleIds = ref<string[]>([]);
|
|
609
639
|
const qrReissuePlate = ref<TPlateNumber | null>(null);
|
|
610
640
|
const qrReissuing = ref(false);
|
|
@@ -635,7 +665,8 @@ const qrCodeToLink = computed(() => String(qrLinkCode.value || qrSearch.value ||
|
|
|
635
665
|
|
|
636
666
|
// Saved blank codes still free to link + the site's printed / linked / available count.
|
|
637
667
|
async function loadQrAvailable(search = "") {
|
|
638
|
-
|
|
668
|
+
// The Link dialog lists the blanks for whoever may link this plate.
|
|
669
|
+
if (!qrStickersOn.value && !(dialog.qrLink && qrActionsOn(qrPlate.value))) return;
|
|
639
670
|
try {
|
|
640
671
|
const res = await getAvailableBlankVehicleQr(props.site, { search, limit: 50 });
|
|
641
672
|
qrSummary.value = res?.summary ?? null;
|
|
@@ -1139,7 +1170,10 @@ const vehiclePermissions = computed(() => ({
|
|
|
1139
1170
|
canUpdateVehicle: props.canUpdateVehicle && !revoked.update,
|
|
1140
1171
|
canDeleteVehicle: props.canDeleteVehicle && !revoked.delete,
|
|
1141
1172
|
canApproveVehicle: props.canApproveVehicle && !revoked.approve,
|
|
1142
|
-
canManageBlocklist:
|
|
1173
|
+
canManageBlocklist:
|
|
1174
|
+
props.canManageBlocklist === undefined
|
|
1175
|
+
? undefined
|
|
1176
|
+
: props.canManageBlocklist && !revoked.blocklist,
|
|
1143
1177
|
}));
|
|
1144
1178
|
|
|
1145
1179
|
/**
|
|
@@ -1153,8 +1187,26 @@ const vehiclePermissions = computed(() => ({
|
|
|
1153
1187
|
* the local override is dropped and the host's answer stands again - so a
|
|
1154
1188
|
* right given back is not kept hidden.
|
|
1155
1189
|
*/
|
|
1156
|
-
const revoked = reactive({ update: false, delete: false, approve: false });
|
|
1190
|
+
const revoked = reactive({ update: false, delete: false, approve: false, blocklist: false });
|
|
1157
1191
|
watch(() => props.canUpdateVehicle, () => { revoked.update = false; });
|
|
1192
|
+
watch(() => props.canManageBlocklist, () => { revoked.blocklist = false; });
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Which right an action on the selected plate stands on. With Manage Blocklist
|
|
1196
|
+
* passed by the host, Block / Unblock - and Edit / Delete on a Block Listed
|
|
1197
|
+
* plate - are that right, so a refusal there takes only those off the menus.
|
|
1198
|
+
*/
|
|
1199
|
+
/** Restore on a Block Listed plate is a blocklist write; any other stays Approve. */
|
|
1200
|
+
function restoreRight(): keyof typeof revoked {
|
|
1201
|
+
if (props.canManageBlocklist === undefined) return "approve";
|
|
1202
|
+
return selectedPlateNumberObject.value?.type === "blocklist" ? "blocklist" : "approve";
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function rightFor(base: "update" | "delete", blockAction = false): keyof typeof revoked {
|
|
1206
|
+
if (props.canManageBlocklist === undefined) return base;
|
|
1207
|
+
if (blockAction || selectedPlateNumberObject.value?.type === "blocklist") return "blocklist";
|
|
1208
|
+
return base;
|
|
1209
|
+
}
|
|
1158
1210
|
watch(() => props.canDeleteVehicle, () => { revoked.delete = false; });
|
|
1159
1211
|
watch(() => props.canApproveVehicle, () => { revoked.approve = false; });
|
|
1160
1212
|
|
|
@@ -1167,6 +1219,7 @@ const NO_PERMISSION: Record<keyof typeof revoked, string> = {
|
|
|
1167
1219
|
update: "You do not have permission to edit vehicles.",
|
|
1168
1220
|
delete: "You do not have permission to delete vehicles.",
|
|
1169
1221
|
approve: "You do not have permission to approve vehicles.",
|
|
1222
|
+
blocklist: "You do not have permission to manage the block list.",
|
|
1170
1223
|
};
|
|
1171
1224
|
|
|
1172
1225
|
/**
|
|
@@ -1176,7 +1229,7 @@ const NO_PERMISSION: Record<keyof typeof revoked, string> = {
|
|
|
1176
1229
|
*/
|
|
1177
1230
|
/** The add/edit form was refused: an edit refusal takes Edit/Block/Unblock off. */
|
|
1178
1231
|
function handleFormRefused(kind: "create" | "update") {
|
|
1179
|
-
if (kind === "update") revoked
|
|
1232
|
+
if (kind === "update") revoked[rightFor("update")] = true;
|
|
1180
1233
|
}
|
|
1181
1234
|
|
|
1182
1235
|
function refusedAlready(right: keyof typeof revoked, target: typeof actionError) {
|
|
@@ -1266,7 +1319,7 @@ const formatPlateNumbers = (value: any) => {
|
|
|
1266
1319
|
};
|
|
1267
1320
|
|
|
1268
1321
|
async function submitDelete() {
|
|
1269
|
-
if (refusedAlready("delete", deleteError)) return;
|
|
1322
|
+
if (refusedAlready(rightFor("delete"), deleteError)) return;
|
|
1270
1323
|
|
|
1271
1324
|
const plateNumberId = selectedPlateNumberObject.value?._id;
|
|
1272
1325
|
const type = selectedPlateNumberObject.value?.type as TVehicleType;
|
|
@@ -1292,7 +1345,7 @@ async function submitDelete() {
|
|
|
1292
1345
|
// error - so a refused delete showed the operator nothing at all.
|
|
1293
1346
|
deleteError.value = errorConverter(error);
|
|
1294
1347
|
// Refused for want of the right: take Delete off both menus now.
|
|
1295
|
-
revokeOnRefusal(error, "delete");
|
|
1348
|
+
revokeOnRefusal(error, rightFor("delete"));
|
|
1296
1349
|
// The dialog stays open and the row stays in the table - nothing is removed
|
|
1297
1350
|
// optimistically, so what is on screen still matches the database.
|
|
1298
1351
|
} finally {
|
|
@@ -1328,7 +1381,7 @@ const restorePrompt = computed(() => {
|
|
|
1328
1381
|
});
|
|
1329
1382
|
|
|
1330
1383
|
async function submitRestore() {
|
|
1331
|
-
if (refusedAlready(
|
|
1384
|
+
if (refusedAlready(restoreRight(), actionError)) return;
|
|
1332
1385
|
|
|
1333
1386
|
const vehicleId = selectedPlateNumberObject.value?._id;
|
|
1334
1387
|
|
|
@@ -1349,7 +1402,7 @@ async function submitRestore() {
|
|
|
1349
1402
|
} catch (error: any) {
|
|
1350
1403
|
actionError.value = errorConverter(error);
|
|
1351
1404
|
// Restore is the approve endpoint: a refusal is the Approve right gone.
|
|
1352
|
-
revokeOnRefusal(error,
|
|
1405
|
+
revokeOnRefusal(error, restoreRight());
|
|
1353
1406
|
} finally {
|
|
1354
1407
|
restoringVehicle.value = false;
|
|
1355
1408
|
}
|
|
@@ -1357,7 +1410,7 @@ async function submitRestore() {
|
|
|
1357
1410
|
|
|
1358
1411
|
|
|
1359
1412
|
async function submitBlock() {
|
|
1360
|
-
if (refusedAlready("update", actionError)) return;
|
|
1413
|
+
if (refusedAlready(rightFor("update", true), actionError)) return;
|
|
1361
1414
|
const vehicleId = selectedPlateNumberObject.value?._id;
|
|
1362
1415
|
if (!vehicleId) {
|
|
1363
1416
|
showMessage("Invalid vehicle selected for blocking.", "error");
|
|
@@ -1373,7 +1426,7 @@ async function submitBlock() {
|
|
|
1373
1426
|
await getVehiclesRefresh();
|
|
1374
1427
|
} catch (error: any) {
|
|
1375
1428
|
actionError.value = errorConverter(error);
|
|
1376
|
-
revokeOnRefusal(error, "update");
|
|
1429
|
+
revokeOnRefusal(error, rightFor("update", true));
|
|
1377
1430
|
} finally {
|
|
1378
1431
|
blockingVehicle.value = false;
|
|
1379
1432
|
}
|
|
@@ -1381,7 +1434,7 @@ async function submitBlock() {
|
|
|
1381
1434
|
|
|
1382
1435
|
|
|
1383
1436
|
async function submitUnblock() {
|
|
1384
|
-
if (refusedAlready("update", actionError)) return;
|
|
1437
|
+
if (refusedAlready(rightFor("update", true), actionError)) return;
|
|
1385
1438
|
const vehicleId = selectedPlateNumberObject.value?._id;
|
|
1386
1439
|
if (!vehicleId) {
|
|
1387
1440
|
showMessage("Invalid vehicle selected for unblocking.", "error");
|
|
@@ -1397,7 +1450,7 @@ async function submitUnblock() {
|
|
|
1397
1450
|
await getVehiclesRefresh();
|
|
1398
1451
|
} catch (error: any) {
|
|
1399
1452
|
actionError.value = errorConverter(error);
|
|
1400
|
-
revokeOnRefusal(error, "update");
|
|
1453
|
+
revokeOnRefusal(error, rightFor("update", true));
|
|
1401
1454
|
} finally {
|
|
1402
1455
|
unblockingVehicle.value = false;
|
|
1403
1456
|
}
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
<AppButton
|
|
203
203
|
v-if="!visitorInfo.checkIn && !visitorInfo.checkOut"
|
|
204
204
|
:disabled="processing"
|
|
205
|
-
@click="
|
|
205
|
+
@click="checkIn"
|
|
206
206
|
>
|
|
207
207
|
Check In
|
|
208
208
|
</AppButton>
|
|
@@ -240,6 +240,11 @@ const { standardFormatDateTime } = useUtils();
|
|
|
240
240
|
|
|
241
241
|
const visitorInfo = ref<Partial<TVisitor>>({});
|
|
242
242
|
|
|
243
|
+
// The list adds the user's default ANPR gate when it checks the guest in.
|
|
244
|
+
function checkIn() {
|
|
245
|
+
emit("check-in-out", "checkIn", visitorInfo.value);
|
|
246
|
+
}
|
|
247
|
+
|
|
243
248
|
watch(
|
|
244
249
|
() => prop.visitorData,
|
|
245
250
|
(visitorData) => {
|
|
@@ -843,6 +843,8 @@ import usePeople from "../composables/usePeople";
|
|
|
843
843
|
import useHidAmico from "../composables/useHidAmico";
|
|
844
844
|
import useFile from "../composables/useFile";
|
|
845
845
|
import usePassKey from "../composables/usePassKey";
|
|
846
|
+
import { defaultGateForSite } from "../utils/visitor-camera";
|
|
847
|
+
import { barrierCameraOf, barrierOf, type TBarrierOutcome } from "../utils/visitor-barrier";
|
|
846
848
|
import VisitorPassKeyQRScanner from "./VisitorPassKeyQRScanner.vue";
|
|
847
849
|
import {
|
|
848
850
|
DELETE_PERSON_TITLE,
|
|
@@ -978,6 +980,18 @@ const hidQrVisitorId = ref("");
|
|
|
978
980
|
const hidQrNow = ref(Date.now());
|
|
979
981
|
let hidQrTimer: ReturnType<typeof setInterval> | null = null;
|
|
980
982
|
const hidQrPendingCompletion = ref<"done" | "done:more" | null>(null);
|
|
983
|
+
/**
|
|
984
|
+
* What this save was and whether it opened the ANPR barrier, sent with
|
|
985
|
+
* `done` / `done:more` for the list's message (owner, 2026-10-23).
|
|
986
|
+
*/
|
|
987
|
+
const saveResult = ref<{
|
|
988
|
+
action: "add" | "register" | "edit";
|
|
989
|
+
barrier?: TBarrierOutcome;
|
|
990
|
+
/** The camera the barrier is at, "Entry Gate 1". */
|
|
991
|
+
barrierCamera?: string;
|
|
992
|
+
}>({
|
|
993
|
+
action: "add",
|
|
994
|
+
});
|
|
981
995
|
const hidQrPreview = reactive({
|
|
982
996
|
imageUrl: "",
|
|
983
997
|
issuedAt: "",
|
|
@@ -1098,6 +1112,16 @@ const formMode = computed<"add" | "edit" | "register">(() => {
|
|
|
1098
1112
|
return selectedUnregisteredVisitor.value ? "register" : prop.mode;
|
|
1099
1113
|
});
|
|
1100
1114
|
|
|
1115
|
+
/*
|
|
1116
|
+
* The user's default ANPR gate at this site (owner, 2026-10-23), picked on the
|
|
1117
|
+
* visitor list: sent with an add or a register (an edit opens nothing) so that
|
|
1118
|
+
* camera is checked for a blocked plate and opens the barrier. None picked:
|
|
1119
|
+
* not sent, and the server uses the site's first camera.
|
|
1120
|
+
*/
|
|
1121
|
+
const defaultCameraGate = computed(() =>
|
|
1122
|
+
defaultGateForSite((currentUser.value as any)?.defaultCameraGate, prop.site)
|
|
1123
|
+
);
|
|
1124
|
+
|
|
1101
1125
|
// Fields this form actually has for the visitor type - drives both what is
|
|
1102
1126
|
// shown and what is sent on submit, so nothing outside the form is saved.
|
|
1103
1127
|
// typeFieldMap also feeds the details dialog, so it can list more than this:
|
|
@@ -2798,9 +2822,6 @@ const CLEARABLE_ON_UPDATE = [
|
|
|
2798
2822
|
"attachments",
|
|
2799
2823
|
];
|
|
2800
2824
|
const OBJECT_ID = /^[a-f0-9]{24}$/i;
|
|
2801
|
-
// Users who record a visit they are not witnessing: their visit opens with no
|
|
2802
|
-
// check-in / check-out (isecure365 `backOffice`).
|
|
2803
|
-
const BACK_OFFICE_USER_TYPES = /^(service-provider|service-provider-member|site)$/i;
|
|
2804
2825
|
|
|
2805
2826
|
// The site's delivery company list (and the typed name snapped to the
|
|
2806
2827
|
// listed spelling, "grabfood" -> "GrabFood").
|
|
@@ -2986,16 +3007,10 @@ function buildPayload(isUpdate: boolean): Record<string, unknown> {
|
|
|
2986
3007
|
|
|
2987
3008
|
payload.org = prop.org;
|
|
2988
3009
|
if (userId) payload.createdBy = userId;
|
|
2989
|
-
//
|
|
2990
|
-
//
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
payload.clientUserType = userType;
|
|
2994
|
-
payload.checkIn = null;
|
|
2995
|
-
payload.checkOut = null;
|
|
2996
|
-
} else {
|
|
2997
|
-
payload.checkIn = new Date().toISOString();
|
|
2998
|
-
}
|
|
3010
|
+
// Adding a visitor IS their check-in, whoever adds them - a guard or a
|
|
3011
|
+
// property management user (owner, 2026-10-26). No user type is sent: core
|
|
3012
|
+
// does not take one and refuses it ("clientUserType" is not allowed).
|
|
3013
|
+
payload.checkIn = new Date().toISOString();
|
|
2999
3014
|
return payload;
|
|
3000
3015
|
}
|
|
3001
3016
|
|
|
@@ -3138,6 +3153,16 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
|
|
|
3138
3153
|
}
|
|
3139
3154
|
}
|
|
3140
3155
|
const payload = buildPayload(isUpdate);
|
|
3156
|
+
// The camera chosen at the bottom of the form (not stored on the visit).
|
|
3157
|
+
if (
|
|
3158
|
+
(formMode.value === "add" || formMode.value === "register") &&
|
|
3159
|
+
defaultCameraGate.value &&
|
|
3160
|
+
payload.plateNumber
|
|
3161
|
+
) {
|
|
3162
|
+
payload.anprGate = defaultCameraGate.value;
|
|
3163
|
+
}
|
|
3164
|
+
// A new visit replies `{ _id, barrier }`: whether the barrier opened.
|
|
3165
|
+
if (!isUpdate) payload.reportBarrier = true;
|
|
3141
3166
|
|
|
3142
3167
|
try {
|
|
3143
3168
|
const res = isUpdate
|
|
@@ -3158,6 +3183,11 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
|
|
|
3158
3183
|
}
|
|
3159
3184
|
|
|
3160
3185
|
const visitorId = isUpdate ? recordId : getVisitorIdFromResponse(res);
|
|
3186
|
+
saveResult.value = {
|
|
3187
|
+
action: !isUpdate ? "add" : formMode.value === "register" ? "register" : "edit",
|
|
3188
|
+
barrier: barrierOf(res),
|
|
3189
|
+
barrierCamera: barrierCameraOf(res),
|
|
3190
|
+
};
|
|
3161
3191
|
|
|
3162
3192
|
if (passType.value === "HID_QR") {
|
|
3163
3193
|
const previewReady = await prepareHidQrCodePreview(visitorId);
|
|
@@ -3177,8 +3207,8 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
|
|
|
3177
3207
|
}
|
|
3178
3208
|
|
|
3179
3209
|
if (createMore.value) {
|
|
3180
|
-
emit("done:more");
|
|
3181
|
-
} else emit("done");
|
|
3210
|
+
emit("done:more", saveResult.value);
|
|
3211
|
+
} else emit("done", saveResult.value);
|
|
3182
3212
|
} catch (error: any) {
|
|
3183
3213
|
const err = error?.data?.message || error?.response?._data?.message;
|
|
3184
3214
|
errorMessage.value =
|
|
@@ -3470,8 +3500,8 @@ function closeHidQrPreview() {
|
|
|
3470
3500
|
hidQrPreviewDialog.value = false;
|
|
3471
3501
|
const completion = hidQrPendingCompletion.value;
|
|
3472
3502
|
hidQrPendingCompletion.value = null;
|
|
3473
|
-
if (completion === "done:more") emit("done:more");
|
|
3474
|
-
else if (completion === "done") emit("done");
|
|
3503
|
+
if (completion === "done:more") emit("done:more", saveResult.value);
|
|
3504
|
+
else if (completion === "done") emit("done", saveResult.value);
|
|
3475
3505
|
}
|
|
3476
3506
|
|
|
3477
3507
|
watch(
|
|
@@ -57,6 +57,26 @@
|
|
|
57
57
|
show-header
|
|
58
58
|
@row-click="handleRowClick"
|
|
59
59
|
>
|
|
60
|
+
<!-- The default ANPR camera (owner, 2026-10-23): only when the site has
|
|
61
|
+
more than one active Entry / Both / Visitors camera. Add, register
|
|
62
|
+
and guest check-in use it. -->
|
|
63
|
+
<template #pagination-prepend>
|
|
64
|
+
<v-btn
|
|
65
|
+
v-if="anprCameraCount > 1"
|
|
66
|
+
icon
|
|
67
|
+
density="comfortable"
|
|
68
|
+
variant="text"
|
|
69
|
+
:color="defaultCameraGate ? 'primary' : undefined"
|
|
70
|
+
:aria-label="'Default ANPR camera'"
|
|
71
|
+
data-test="visitor-default-camera"
|
|
72
|
+
@click="openDefaultCamera"
|
|
73
|
+
>
|
|
74
|
+
<v-icon>mdi-cctv</v-icon>
|
|
75
|
+
<v-tooltip activator="parent" location="top">
|
|
76
|
+
{{ defaultCameraGateLabel }}
|
|
77
|
+
</v-tooltip>
|
|
78
|
+
</v-btn>
|
|
79
|
+
</template>
|
|
60
80
|
<template #extension>
|
|
61
81
|
<v-row no-gutters class="w-100 d-flex flex-column">
|
|
62
82
|
<v-tabs
|
|
@@ -1814,7 +1834,44 @@
|
|
|
1814
1834
|
</v-card>
|
|
1815
1835
|
</v-dialog>
|
|
1816
1836
|
|
|
1817
|
-
<
|
|
1837
|
+
<v-dialog v-model="dialog.defaultCamera" width="450" persistent>
|
|
1838
|
+
<v-card class="screen-modal" width="100%" :loading="savingDefaultCamera">
|
|
1839
|
+
<v-card-title>{{ VISITOR_CAMERA_LABEL }}</v-card-title>
|
|
1840
|
+
<v-card-text>
|
|
1841
|
+
<p class="mb-4 text-body-2">
|
|
1842
|
+
Add, register and guest check-in use this camera: it is checked for a
|
|
1843
|
+
blocked vehicle and opens the barrier. None chosen: the site's first camera.
|
|
1844
|
+
</p>
|
|
1845
|
+
<v-select
|
|
1846
|
+
v-if="anprGateChoices.length"
|
|
1847
|
+
v-model="pickedDefaultGate"
|
|
1848
|
+
:items="anprGateChoices"
|
|
1849
|
+
item-title="title"
|
|
1850
|
+
item-value="value"
|
|
1851
|
+
density="comfortable"
|
|
1852
|
+
clearable
|
|
1853
|
+
placeholder="None (the site's first camera)"
|
|
1854
|
+
data-test="visitor-default-camera-select"
|
|
1855
|
+
/>
|
|
1856
|
+
<p v-else class="text-body-2 text-medium-emphasis">
|
|
1857
|
+
No ANPR camera at this site has a gate yet. Set the gates in Settings first.
|
|
1858
|
+
</p>
|
|
1859
|
+
<p v-if="defaultCameraError" class="text-error text-body-2 mt-2">{{ defaultCameraError }}</p>
|
|
1860
|
+
</v-card-text>
|
|
1861
|
+
<v-card-actions>
|
|
1862
|
+
<v-spacer />
|
|
1863
|
+
<AppButton variant="ghost" :disabled="savingDefaultCamera" @click="dialog.defaultCamera = false">
|
|
1864
|
+
Cancel
|
|
1865
|
+
</AppButton>
|
|
1866
|
+
<AppButton :disabled="savingDefaultCamera" data-test="visitor-default-camera-submit" @click="submitDefaultCamera">
|
|
1867
|
+
Submit
|
|
1868
|
+
</AppButton>
|
|
1869
|
+
</v-card-actions>
|
|
1870
|
+
</v-card>
|
|
1871
|
+
</v-dialog>
|
|
1872
|
+
|
|
1873
|
+
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor"
|
|
1874
|
+
:detail="messageDetail.text" :detail-tone="messageDetail.tone" />
|
|
1818
1875
|
</v-row>
|
|
1819
1876
|
</template>
|
|
1820
1877
|
|
|
@@ -1823,6 +1880,9 @@ import type { PropType } from "vue";
|
|
|
1823
1880
|
import moment from "moment-timezone";
|
|
1824
1881
|
import QRCode from "qrcode";
|
|
1825
1882
|
import { isVisitorPassSmsOn } from "../composables/useVisitor";
|
|
1883
|
+
import { barrierCameraOf, barrierMessage, barrierOf, VISITOR_ACTION_SUCCESS } from "../utils/visitor-barrier";
|
|
1884
|
+
import useVisitorCameraChoices from "../composables/useVisitorCameraChoices";
|
|
1885
|
+
import { VISITOR_CAMERA_LABEL } from "../utils/visitor-camera";
|
|
1826
1886
|
import usePassKey from "../composables/usePassKey";
|
|
1827
1887
|
import {
|
|
1828
1888
|
VISITOR_SEARCH_DEBOUNCE_MS,
|
|
@@ -2236,6 +2296,45 @@ const mode = ref<"add" | "edit" | "register">("add");
|
|
|
2236
2296
|
const message = ref("");
|
|
2237
2297
|
const messageColor = ref("");
|
|
2238
2298
|
const messageSnackbar = ref(false);
|
|
2299
|
+
|
|
2300
|
+
/* The default ANPR camera (gate) for add / register / guest check-in. */
|
|
2301
|
+
const {
|
|
2302
|
+
count: anprCameraCount,
|
|
2303
|
+
choices: anprGateChoices,
|
|
2304
|
+
defaultGate: defaultCameraGate,
|
|
2305
|
+
saveDefaultGate,
|
|
2306
|
+
} = useVisitorCameraChoices(() => siteId);
|
|
2307
|
+
const pickedDefaultGate = ref<number | null>(null);
|
|
2308
|
+
const savingDefaultCamera = ref(false);
|
|
2309
|
+
const defaultCameraError = ref("");
|
|
2310
|
+
const defaultCameraGateLabel = computed(() => {
|
|
2311
|
+
const gate = defaultCameraGate.value;
|
|
2312
|
+
if (!gate) return "Default ANPR camera: the site's first camera";
|
|
2313
|
+
const choice = anprGateChoices.value.find((item) => item.value === gate);
|
|
2314
|
+
return `Default ANPR camera: ${choice?.title ?? `Gate ${gate}`}`;
|
|
2315
|
+
});
|
|
2316
|
+
|
|
2317
|
+
function openDefaultCamera() {
|
|
2318
|
+
pickedDefaultGate.value = defaultCameraGate.value ?? null;
|
|
2319
|
+
defaultCameraError.value = "";
|
|
2320
|
+
dialog.defaultCamera = true;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
async function submitDefaultCamera() {
|
|
2324
|
+
savingDefaultCamera.value = true;
|
|
2325
|
+
defaultCameraError.value = "";
|
|
2326
|
+
try {
|
|
2327
|
+
const res = await saveDefaultGate(pickedDefaultGate.value || null);
|
|
2328
|
+
dialog.defaultCamera = false;
|
|
2329
|
+
showMessage(res?.message || "Saved.", "success");
|
|
2330
|
+
} catch (error) {
|
|
2331
|
+
defaultCameraError.value = errorConverter(error);
|
|
2332
|
+
} finally {
|
|
2333
|
+
savingDefaultCamera.value = false;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
// The toast's second line: whether the barrier opened.
|
|
2337
|
+
const messageDetail = ref<{ text: string; tone: "default" | "error" }>({ text: "", tone: "default" });
|
|
2239
2338
|
/** Why the list could not be loaded (shown in the empty table). */
|
|
2240
2339
|
const visitorListError = ref("");
|
|
2241
2340
|
|
|
@@ -2286,6 +2385,7 @@ const dialog = reactive({
|
|
|
2286
2385
|
showSelection: false,
|
|
2287
2386
|
showForm: false,
|
|
2288
2387
|
viewVisitor: false,
|
|
2388
|
+
defaultCamera: false,
|
|
2289
2389
|
snapshotImage: false,
|
|
2290
2390
|
remarks: false,
|
|
2291
2391
|
returnPassesKeys: false,
|
|
@@ -3182,8 +3282,13 @@ async function handleVisitorDataFromScannedQRCodeCheckInOut(
|
|
|
3182
3282
|
? visitorData.visitorPass
|
|
3183
3283
|
: [];
|
|
3184
3284
|
|
|
3185
|
-
await visitorDataFromScannedQRCodeCheckIn({
|
|
3285
|
+
const checkInReply: any = await visitorDataFromScannedQRCodeCheckIn({
|
|
3186
3286
|
type,
|
|
3287
|
+
// The user's default ANPR gate at this site: checked for a blocked
|
|
3288
|
+
// plate, opens the barrier. None: the site's first camera.
|
|
3289
|
+
...(defaultCameraGate.value && String(visitorData.plateNumber ?? "").trim()
|
|
3290
|
+
? { anprGate: defaultCameraGate.value }
|
|
3291
|
+
: {}),
|
|
3187
3292
|
filter: {
|
|
3188
3293
|
_id: visitorId,
|
|
3189
3294
|
site: visitorData.site ?? siteId,
|
|
@@ -3200,7 +3305,13 @@ async function handleVisitorDataFromScannedQRCodeCheckInOut(
|
|
|
3200
3305
|
},
|
|
3201
3306
|
userType: "site",
|
|
3202
3307
|
});
|
|
3203
|
-
|
|
3308
|
+
// "Check In Visitor Success." and whether the barrier opened.
|
|
3309
|
+
showMessage(
|
|
3310
|
+
VISITOR_ACTION_SUCCESS.checkIn,
|
|
3311
|
+
"success",
|
|
3312
|
+
barrierOf(checkInReply),
|
|
3313
|
+
barrierCameraOf(checkInReply),
|
|
3314
|
+
);
|
|
3204
3315
|
} else {
|
|
3205
3316
|
selectedVisitorId.value = visitorData._id;
|
|
3206
3317
|
await proceedCheckout();
|
|
@@ -3426,23 +3537,49 @@ function handleClickBack() {
|
|
|
3426
3537
|
/** iSecure's toast after an add, an update or a register. */
|
|
3427
3538
|
const VISITOR_SAVED_MESSAGE = "Great! A new visitor has been successfully added.";
|
|
3428
3539
|
|
|
3429
|
-
|
|
3430
|
-
|
|
3540
|
+
/*
|
|
3541
|
+
* After a save (owner, 2026-10-23): "Add Visitor Success." / "Register Visitor
|
|
3542
|
+
* Success." and, when the barrier was asked to open, "Opening Barrier
|
|
3543
|
+
* Success." or "Opening Barrier Failed." (red). An edit keeps its message.
|
|
3544
|
+
*/
|
|
3545
|
+
type TVisitorFormResult = {
|
|
3546
|
+
action?: "add" | "register" | "edit";
|
|
3547
|
+
barrier?: "opened" | "failed";
|
|
3548
|
+
/** The camera the barrier is at, "Entry Gate 1". */
|
|
3549
|
+
barrierCamera?: string;
|
|
3550
|
+
};
|
|
3551
|
+
|
|
3552
|
+
function visitorSavedMessage(result?: TVisitorFormResult) {
|
|
3553
|
+
if (result?.action === "add") return VISITOR_ACTION_SUCCESS.add;
|
|
3554
|
+
if (result?.action === "register") return VISITOR_ACTION_SUCCESS.register;
|
|
3555
|
+
return VISITOR_SAVED_MESSAGE;
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
function handleVisitorFormDone(result?: TVisitorFormResult) {
|
|
3559
|
+
showMessage(visitorSavedMessage(result), "success", result?.barrier, result?.barrierCamera);
|
|
3431
3560
|
getVisitorRefresh();
|
|
3432
3561
|
dialog.showSelection = false;
|
|
3433
3562
|
dialog.showForm = false;
|
|
3434
3563
|
}
|
|
3435
3564
|
|
|
3436
|
-
function handleVisitorFormCreateMore() {
|
|
3437
|
-
showMessage(
|
|
3565
|
+
function handleVisitorFormCreateMore(result?: TVisitorFormResult) {
|
|
3566
|
+
showMessage(visitorSavedMessage(result), "success", result?.barrier, result?.barrierCamera);
|
|
3438
3567
|
getVisitorRefresh();
|
|
3439
3568
|
dialog.showSelection = true;
|
|
3440
3569
|
dialog.showForm = false;
|
|
3441
3570
|
}
|
|
3442
3571
|
|
|
3443
|
-
function showMessage(
|
|
3572
|
+
function showMessage(
|
|
3573
|
+
msg: string,
|
|
3574
|
+
color: string,
|
|
3575
|
+
barrier?: "opened" | "failed",
|
|
3576
|
+
barrierCamera?: string,
|
|
3577
|
+
) {
|
|
3444
3578
|
message.value = msg;
|
|
3445
3579
|
messageColor.value = color;
|
|
3580
|
+
messageDetail.value = barrier
|
|
3581
|
+
? { text: barrierMessage(barrier, barrierCamera), tone: barrier === "failed" ? "error" : "default" }
|
|
3582
|
+
: { text: "", tone: "default" };
|
|
3446
3583
|
messageSnackbar.value = true;
|
|
3447
3584
|
}
|
|
3448
3585
|
|