@7365admin1/layer-common 4.97.1-staging.599 → 4.97.1-staging.601

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.
@@ -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="qrStickersOn" title="Re-issue sticker (lost or stolen)" prepend-icon="mdi-qrcode-edit"
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="qrStickersOn" title="Undo re-issue" prepend-icon="mdi-undo"
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="qrStickersOn" title="Link QR sticker" prepend-icon="mdi-link-variant"
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="qrStickersOn" title="Remove QR sticker" prepend-icon="mdi-link-variant-off"
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="qrStickersOn" title="Re-issue sticker (lost or stolen)" prepend-icon="mdi-qrcode-edit"
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="qrStickersOn" title="Undo re-issue" prepend-icon="mdi-undo"
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="qrStickersOn" title="Link QR sticker" prepend-icon="mdi-link-variant"
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="qrStickersOn" title="Remove QR sticker" prepend-icon="mdi-link-variant-off"
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.delete"
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.approve"
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.update"
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.update"
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
- if (!qrStickersOn.value) return;
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: props.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.update = true;
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("approve", actionError)) return;
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, "approve");
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="emit('check-in-out', 'checkIn', visitorInfo)"
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) => {
@@ -845,6 +845,8 @@ import useHidAmico from "../composables/useHidAmico";
845
845
  import { hidQrUsageFromSettings, minutesUntilEndOfDay } from "../utils/hid-qr-validity";
846
846
  import useFile from "../composables/useFile";
847
847
  import usePassKey from "../composables/usePassKey";
848
+ import { defaultGateForSite } from "../utils/visitor-camera";
849
+ import { barrierCameraOf, barrierOf, type TBarrierOutcome } from "../utils/visitor-barrier";
848
850
  import VisitorPassKeyQRScanner from "./VisitorPassKeyQRScanner.vue";
849
851
  import {
850
852
  DELETE_PERSON_TITLE,
@@ -980,6 +982,18 @@ const hidQrVisitorId = ref("");
980
982
  const hidQrNow = ref(Date.now());
981
983
  let hidQrTimer: ReturnType<typeof setInterval> | null = null;
982
984
  const hidQrPendingCompletion = ref<"done" | "done:more" | null>(null);
985
+ /**
986
+ * What this save was and whether it opened the ANPR barrier, sent with
987
+ * `done` / `done:more` for the list's message (owner, 2026-10-23).
988
+ */
989
+ const saveResult = ref<{
990
+ action: "add" | "register" | "edit";
991
+ barrier?: TBarrierOutcome;
992
+ /** The camera the barrier is at, "Entry Gate 1". */
993
+ barrierCamera?: string;
994
+ }>({
995
+ action: "add",
996
+ });
983
997
  const hidQrPreview = reactive({
984
998
  imageUrl: "",
985
999
  issuedAt: "",
@@ -1100,6 +1114,16 @@ const formMode = computed<"add" | "edit" | "register">(() => {
1100
1114
  return selectedUnregisteredVisitor.value ? "register" : prop.mode;
1101
1115
  });
1102
1116
 
1117
+ /*
1118
+ * The user's default ANPR gate at this site (owner, 2026-10-23), picked on the
1119
+ * visitor list: sent with an add or a register (an edit opens nothing) so that
1120
+ * camera is checked for a blocked plate and opens the barrier. None picked:
1121
+ * not sent, and the server uses the site's first camera.
1122
+ */
1123
+ const defaultCameraGate = computed(() =>
1124
+ defaultGateForSite((currentUser.value as any)?.defaultCameraGate, prop.site)
1125
+ );
1126
+
1103
1127
  // Fields this form actually has for the visitor type - drives both what is
1104
1128
  // shown and what is sent on submit, so nothing outside the form is saved.
1105
1129
  // typeFieldMap also feeds the details dialog, so it can list more than this:
@@ -2821,9 +2845,6 @@ const CLEARABLE_ON_UPDATE = [
2821
2845
  "attachments",
2822
2846
  ];
2823
2847
  const OBJECT_ID = /^[a-f0-9]{24}$/i;
2824
- // Users who record a visit they are not witnessing: their visit opens with no
2825
- // check-in / check-out (isecure365 `backOffice`).
2826
- const BACK_OFFICE_USER_TYPES = /^(service-provider|service-provider-member|site)$/i;
2827
2848
 
2828
2849
  // The site's delivery company list (and the typed name snapped to the
2829
2850
  // listed spelling, "grabfood" -> "GrabFood").
@@ -3009,16 +3030,10 @@ function buildPayload(isUpdate: boolean): Record<string, unknown> {
3009
3030
 
3010
3031
  payload.org = prop.org;
3011
3032
  if (userId) payload.createdBy = userId;
3012
- // Registering a visitor at the guardhouse IS their arrival: checked in as it
3013
- // is created. A back-office user's visit opens with no times at all.
3014
- const userType = currentUser.value?.type ?? "";
3015
- if (BACK_OFFICE_USER_TYPES.test(userType)) {
3016
- payload.clientUserType = userType;
3017
- payload.checkIn = null;
3018
- payload.checkOut = null;
3019
- } else {
3020
- payload.checkIn = new Date().toISOString();
3021
- }
3033
+ // Adding a visitor IS their check-in, whoever adds them - a guard or a
3034
+ // property management user (owner, 2026-10-26). No user type is sent: core
3035
+ // does not take one and refuses it ("clientUserType" is not allowed).
3036
+ payload.checkIn = new Date().toISOString();
3022
3037
  return payload;
3023
3038
  }
3024
3039
 
@@ -3161,6 +3176,16 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
3161
3176
  }
3162
3177
  }
3163
3178
  const payload = buildPayload(isUpdate);
3179
+ // The camera chosen at the bottom of the form (not stored on the visit).
3180
+ if (
3181
+ (formMode.value === "add" || formMode.value === "register") &&
3182
+ defaultCameraGate.value &&
3183
+ payload.plateNumber
3184
+ ) {
3185
+ payload.anprGate = defaultCameraGate.value;
3186
+ }
3187
+ // A new visit replies `{ _id, barrier }`: whether the barrier opened.
3188
+ if (!isUpdate) payload.reportBarrier = true;
3164
3189
 
3165
3190
  try {
3166
3191
  const res = isUpdate
@@ -3181,6 +3206,11 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
3181
3206
  }
3182
3207
 
3183
3208
  const visitorId = isUpdate ? recordId : getVisitorIdFromResponse(res);
3209
+ saveResult.value = {
3210
+ action: !isUpdate ? "add" : formMode.value === "register" ? "register" : "edit",
3211
+ barrier: barrierOf(res),
3212
+ barrierCamera: barrierCameraOf(res),
3213
+ };
3184
3214
 
3185
3215
  if (passType.value === "HID_QR") {
3186
3216
  const previewReady = await prepareHidQrCodePreview(visitorId);
@@ -3200,8 +3230,8 @@ async function submit(options: { skipNonCheckedOutCheck?: boolean } = {}) {
3200
3230
  }
3201
3231
 
3202
3232
  if (createMore.value) {
3203
- emit("done:more");
3204
- } else emit("done");
3233
+ emit("done:more", saveResult.value);
3234
+ } else emit("done", saveResult.value);
3205
3235
  } catch (error: any) {
3206
3236
  const err = error?.data?.message || error?.response?._data?.message;
3207
3237
  errorMessage.value =
@@ -3495,8 +3525,8 @@ function closeHidQrPreview() {
3495
3525
  hidQrPreviewDialog.value = false;
3496
3526
  const completion = hidQrPendingCompletion.value;
3497
3527
  hidQrPendingCompletion.value = null;
3498
- if (completion === "done:more") emit("done:more");
3499
- else if (completion === "done") emit("done");
3528
+ if (completion === "done:more") emit("done:more", saveResult.value);
3529
+ else if (completion === "done") emit("done", saveResult.value);
3500
3530
  }
3501
3531
 
3502
3532
  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
@@ -1813,7 +1833,44 @@
1813
1833
  </v-card>
1814
1834
  </v-dialog>
1815
1835
 
1816
- <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
1836
+ <v-dialog v-model="dialog.defaultCamera" width="450" persistent>
1837
+ <v-card class="screen-modal" width="100%" :loading="savingDefaultCamera">
1838
+ <v-card-title>{{ VISITOR_CAMERA_LABEL }}</v-card-title>
1839
+ <v-card-text>
1840
+ <p class="mb-4 text-body-2">
1841
+ Add, register and guest check-in use this camera: it is checked for a
1842
+ blocked vehicle and opens the barrier. None chosen: the site's first camera.
1843
+ </p>
1844
+ <v-select
1845
+ v-if="anprGateChoices.length"
1846
+ v-model="pickedDefaultGate"
1847
+ :items="anprGateChoices"
1848
+ item-title="title"
1849
+ item-value="value"
1850
+ density="comfortable"
1851
+ clearable
1852
+ placeholder="None (the site's first camera)"
1853
+ data-test="visitor-default-camera-select"
1854
+ />
1855
+ <p v-else class="text-body-2 text-medium-emphasis">
1856
+ No ANPR camera at this site has a gate yet. Set the gates in Settings first.
1857
+ </p>
1858
+ <p v-if="defaultCameraError" class="text-error text-body-2 mt-2">{{ defaultCameraError }}</p>
1859
+ </v-card-text>
1860
+ <v-card-actions>
1861
+ <v-spacer />
1862
+ <AppButton variant="ghost" :disabled="savingDefaultCamera" @click="dialog.defaultCamera = false">
1863
+ Cancel
1864
+ </AppButton>
1865
+ <AppButton :disabled="savingDefaultCamera" data-test="visitor-default-camera-submit" @click="submitDefaultCamera">
1866
+ Submit
1867
+ </AppButton>
1868
+ </v-card-actions>
1869
+ </v-card>
1870
+ </v-dialog>
1871
+
1872
+ <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor"
1873
+ :detail="messageDetail.text" :detail-tone="messageDetail.tone" />
1817
1874
  </v-row>
1818
1875
  </template>
1819
1876
 
@@ -1822,6 +1879,9 @@ import type { PropType } from "vue";
1822
1879
  import moment from "moment-timezone";
1823
1880
  import QRCode from "qrcode";
1824
1881
  import { isVisitorPassSmsOn } from "../composables/useVisitor";
1882
+ import { barrierCameraOf, barrierMessage, barrierOf, VISITOR_ACTION_SUCCESS } from "../utils/visitor-barrier";
1883
+ import useVisitorCameraChoices from "../composables/useVisitorCameraChoices";
1884
+ import { VISITOR_CAMERA_LABEL } from "../utils/visitor-camera";
1825
1885
  import usePassKey from "../composables/usePassKey";
1826
1886
  import {
1827
1887
  VISITOR_SEARCH_DEBOUNCE_MS,
@@ -2207,6 +2267,45 @@ const mode = ref<"add" | "edit" | "register">("add");
2207
2267
  const message = ref("");
2208
2268
  const messageColor = ref("");
2209
2269
  const messageSnackbar = ref(false);
2270
+
2271
+ /* The default ANPR camera (gate) for add / register / guest check-in. */
2272
+ const {
2273
+ count: anprCameraCount,
2274
+ choices: anprGateChoices,
2275
+ defaultGate: defaultCameraGate,
2276
+ saveDefaultGate,
2277
+ } = useVisitorCameraChoices(() => siteId);
2278
+ const pickedDefaultGate = ref<number | null>(null);
2279
+ const savingDefaultCamera = ref(false);
2280
+ const defaultCameraError = ref("");
2281
+ const defaultCameraGateLabel = computed(() => {
2282
+ const gate = defaultCameraGate.value;
2283
+ if (!gate) return "Default ANPR camera: the site's first camera";
2284
+ const choice = anprGateChoices.value.find((item) => item.value === gate);
2285
+ return `Default ANPR camera: ${choice?.title ?? `Gate ${gate}`}`;
2286
+ });
2287
+
2288
+ function openDefaultCamera() {
2289
+ pickedDefaultGate.value = defaultCameraGate.value ?? null;
2290
+ defaultCameraError.value = "";
2291
+ dialog.defaultCamera = true;
2292
+ }
2293
+
2294
+ async function submitDefaultCamera() {
2295
+ savingDefaultCamera.value = true;
2296
+ defaultCameraError.value = "";
2297
+ try {
2298
+ const res = await saveDefaultGate(pickedDefaultGate.value || null);
2299
+ dialog.defaultCamera = false;
2300
+ showMessage(res?.message || "Saved.", "success");
2301
+ } catch (error) {
2302
+ defaultCameraError.value = errorConverter(error);
2303
+ } finally {
2304
+ savingDefaultCamera.value = false;
2305
+ }
2306
+ }
2307
+ // The toast's second line: whether the barrier opened.
2308
+ const messageDetail = ref<{ text: string; tone: "default" | "error" }>({ text: "", tone: "default" });
2210
2309
  /** Why the list could not be loaded (shown in the empty table). */
2211
2310
  const visitorListError = ref("");
2212
2311
 
@@ -2257,6 +2356,7 @@ const dialog = reactive({
2257
2356
  showSelection: false,
2258
2357
  showForm: false,
2259
2358
  viewVisitor: false,
2359
+ defaultCamera: false,
2260
2360
  snapshotImage: false,
2261
2361
  remarks: false,
2262
2362
  returnPassesKeys: false,
@@ -3153,8 +3253,13 @@ async function handleVisitorDataFromScannedQRCodeCheckInOut(
3153
3253
  ? visitorData.visitorPass
3154
3254
  : [];
3155
3255
 
3156
- await visitorDataFromScannedQRCodeCheckIn({
3256
+ const checkInReply: any = await visitorDataFromScannedQRCodeCheckIn({
3157
3257
  type,
3258
+ // The user's default ANPR gate at this site: checked for a blocked
3259
+ // plate, opens the barrier. None: the site's first camera.
3260
+ ...(defaultCameraGate.value && String(visitorData.plateNumber ?? "").trim()
3261
+ ? { anprGate: defaultCameraGate.value }
3262
+ : {}),
3158
3263
  filter: {
3159
3264
  _id: visitorId,
3160
3265
  site: visitorData.site ?? siteId,
@@ -3171,7 +3276,13 @@ async function handleVisitorDataFromScannedQRCodeCheckInOut(
3171
3276
  },
3172
3277
  userType: "site",
3173
3278
  });
3174
- showMessage("Visitor successfully checked-in!", "info");
3279
+ // "Check In Visitor Success." and whether the barrier opened.
3280
+ showMessage(
3281
+ VISITOR_ACTION_SUCCESS.checkIn,
3282
+ "success",
3283
+ barrierOf(checkInReply),
3284
+ barrierCameraOf(checkInReply),
3285
+ );
3175
3286
  } else {
3176
3287
  selectedVisitorId.value = visitorData._id;
3177
3288
  await proceedCheckout();
@@ -3390,23 +3501,49 @@ function handleClickBack() {
3390
3501
  /** iSecure's toast after an add, an update or a register. */
3391
3502
  const VISITOR_SAVED_MESSAGE = "Great! A new visitor has been successfully added.";
3392
3503
 
3393
- function handleVisitorFormDone() {
3394
- showMessage(VISITOR_SAVED_MESSAGE, "success");
3504
+ /*
3505
+ * After a save (owner, 2026-10-23): "Add Visitor Success." / "Register Visitor
3506
+ * Success." and, when the barrier was asked to open, "Opening Barrier
3507
+ * Success." or "Opening Barrier Failed." (red). An edit keeps its message.
3508
+ */
3509
+ type TVisitorFormResult = {
3510
+ action?: "add" | "register" | "edit";
3511
+ barrier?: "opened" | "failed";
3512
+ /** The camera the barrier is at, "Entry Gate 1". */
3513
+ barrierCamera?: string;
3514
+ };
3515
+
3516
+ function visitorSavedMessage(result?: TVisitorFormResult) {
3517
+ if (result?.action === "add") return VISITOR_ACTION_SUCCESS.add;
3518
+ if (result?.action === "register") return VISITOR_ACTION_SUCCESS.register;
3519
+ return VISITOR_SAVED_MESSAGE;
3520
+ }
3521
+
3522
+ function handleVisitorFormDone(result?: TVisitorFormResult) {
3523
+ showMessage(visitorSavedMessage(result), "success", result?.barrier, result?.barrierCamera);
3395
3524
  getVisitorRefresh();
3396
3525
  dialog.showSelection = false;
3397
3526
  dialog.showForm = false;
3398
3527
  }
3399
3528
 
3400
- function handleVisitorFormCreateMore() {
3401
- showMessage(VISITOR_SAVED_MESSAGE, "success");
3529
+ function handleVisitorFormCreateMore(result?: TVisitorFormResult) {
3530
+ showMessage(visitorSavedMessage(result), "success", result?.barrier, result?.barrierCamera);
3402
3531
  getVisitorRefresh();
3403
3532
  dialog.showSelection = true;
3404
3533
  dialog.showForm = false;
3405
3534
  }
3406
3535
 
3407
- function showMessage(msg: string, color: string) {
3536
+ function showMessage(
3537
+ msg: string,
3538
+ color: string,
3539
+ barrier?: "opened" | "failed",
3540
+ barrierCamera?: string,
3541
+ ) {
3408
3542
  message.value = msg;
3409
3543
  messageColor.value = color;
3544
+ messageDetail.value = barrier
3545
+ ? { text: barrierMessage(barrier, barrierCamera), tone: barrier === "failed" ? "error" : "default" }
3546
+ : { text: "", tone: "default" };
3410
3547
  messageSnackbar.value = true;
3411
3548
  }
3412
3549