@7365admin1/layer-common 4.146.0 → 4.147.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.147.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a071f19: Visitor list, Add and Register follow the security mobile app (isecure365) for every visitor type - the same functions and process, the web keeps its own styling.
8
+
9
+ Visitor form (Drive-In, Walk-In, Contractor, Delivery, Pick-Up, Drop-Off):
10
+
11
+ - One page with only Submit ("Update" when saving onto a record) - Contractor no longer pages. Order as the app: NRIC, Phone, Vehicle, Name, Contractor / Delivery Type, Block, Level, Unit, Registered Company Name, Company Name, Photos (Delivery, up to 5), Remarks, then Pass & Keys, Members and Entry Pass.
12
+ - Pass & Keys (and a member's Visitor Pass) only with See All Passes And Keys AND Update Passes And Keys (new `canAssignPassKeys` prop, default off). A contractor's pass waits for the Contractor Type; changing to a type that takes another pass asks first and clears the passes picked.
13
+ - Delivery type saved as `food` / `parcel` (Food by default); the delivery company snaps to the listed spelling and a new one is added to the site's list after the save.
14
+ - Saved the same way for every type: empty fields left out of a new visit and sent as null on an update; checked in on save (a back-office user sends `clientUserType`, no times); `createdBy` / `updatedBy`; `status: "registered"` and no `unitName` when registering; pick-up / drop-off remarks default to the type; contractor passes / keys / members as the app (`add: true` on a record).
15
+ - Checks in the app's order: unregistered plate, then not-checked-out (only with an NRIC or phone; not on a plain edit). Skip NRIC and each check-out from the list carry the Submit on by itself; a visitor holding a pass or key records what came back before the check-out.
16
+ - Ban / white list (900 / 901) and a refused pass ("Pass VP01 is In Use") handled for every type. Entry pass only for a contractor with the NFC pass switch, issued for a register too; a failed issue says "Visitor saved, pass not issued".
17
+ - Autofill: looked up across every visitor type (walk-in included, never pick-up / drop-off); one match fills straight away; a registered camera plate fills only the empty fields; companies offered only from the same type; never block / level / unit; chooser Back / Fill In; delete a wrongly saved person from the match cards.
18
+ - Contractor members: "Add Member" opens a dialog (NRIC, Phone, Name, Visitor Pass), the app's duplicate / required checks and the member's not-checked-out check; cards with Remove; up to 10.
19
+
20
+ Visitor list: Visitors / Guests / Unregistered Visitor tabs and filters as the app (Unregistered needs an entry ANPR camera too), 30-day Singapore dates, the app's row content, check-out / pass & key / NFC return dialogs with `updatedBy`, pass & key assigning gated on `canAssignPassKeys`, register picker "Register {plate} as".
21
+
22
+ Earlier in this change (Drive-In and Walk-In):
23
+
24
+ - Pass & Keys sit under Remarks; no keys for Drive-In / Walk-In (`passKeys: []`), one visitor pass (`add: true` when registering a record).
25
+ - Order of checks: unregistered plate, then not-checked-out (now also when registering an unregistered record).
26
+ - New visit: empty fields left out, checked in on save (`checkIn` now); a back-office user (service-provider / site) sends `clientUserType` with no check-in / check-out.
27
+ - Registering: emptied fields are sent as null; no unitName.
28
+ - Picking an unregistered plate match keeps what was typed and takes only the record's plate.
29
+ - A ban / white list answer (code 900 / 901) is shown as an error instead of a save; a pass the server says is in use is unselected.
30
+
31
+ NRIC, as the security mobile app, for every visitor type with an NRIC (Drive-In, Walk-In, Contractor, Delivery) and the contractor member input:
32
+
33
+ - an eye to show / hide the NRIC;
34
+ - "Scan" opens the camera (new `NricScanDialog`) and reads the NRIC and name off the card in the browser (tesseract.js), with the app's own reader (`utils/nric-card.ts`, ported as is): accepted only on a matching check letter (an M-series FIN read twice), number alone after 3 reads without a name. Nothing is uploaded or kept.
35
+
36
+ New plate notice and Register, as the security mobile app:
37
+
38
+ - shown only for a read with both the plate and the record id, when the site's Unregistered switch is on AND it has an entry / both ANPR camera; on every page, the Unregistered tab included; stays until closed (no 20 s timeout);
39
+ - the record for Register is kept apart (`registerVisitorFormData`) and only a new arrival replaces it - camera alerts and list reloads no longer wipe it or cancel a pending Register; cleared on a site switch and when the layout unmounts (logout);
40
+ - the button reads "Register" with the visitor Update right (new `canUpdateVisitor` prop), otherwise "Go to Unregistered"; the type picker opens only with the Update right and the record is used once; the row's Register button now needs Update (was Add);
41
+ - registering an unregistered record runs the not-checked-out check for every type.
42
+ - Register (row or notice) opens the form straight away with only the record's id and the camera's plate (editable) - nothing fetched or looked up in the loaded rows, so a record not on the current page no longer leaves Submit doing nothing; the guard keys in the rest and the save updates the record by its id.
43
+ - Unregistered-plate list: "Cancel & Add New" now carries on with the paused Submit (or Next) straight away as a new visitor, without checking that plate against the Unregistered list again.
44
+ - NRIC scan, as the security mobile app (2026-10-20): pauses on "Check the details" (NRIC and name) with "Fill In" / "Scan Again" instead of filling in by itself; a number whose check letter fails is offered, with a warning, once read the same twice in a row; the name is read without its small "Name" label and the Race value is never taken for it.
45
+
46
+ Delivery photos and company list, richer Preview (2026-10-22):
47
+
48
+ - Delivery form: photos as the app (Gallery / Camera, up to 5, carousel with Edit / Delete, uploaded on Submit as `attachments`); the delivery company list is read when the input is clicked, and each listed company can be renamed or removed (the site's latest list is re-read first). `FileInput` exposes `openCameraDialog`.
49
+ - Preview: Type and Contractor Type first; a delivery's Company; Pass and Keys as the list's chips; the Lead card's pass (names looked up from ids); Photos and ANPR Snapshots in carousels with a full-screen view; the Manual Checkout "i" only for a manual check-out.
50
+ - Pass & key menu and dialog: "Add Key" with no keys held, "Add/Remove Key" once the visitor holds keys.
51
+
3
52
  ## 4.146.0
4
53
 
5
54
  ### Minor Changes
@@ -3432,11 +3481,11 @@ Monthly report: a month that had patrols opens the list of that month's patrol r
3432
3481
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
3433
3482
  own rule from two endpoints the console already calls, unprojected:
3434
3483
 
3435
- GET /api/members/user/:user/app/admin the Seven365 staff membership
3436
- GET /api/roles/id/:role that membership's role document
3484
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
3485
+ GET /api/roles/id/:role that membership's role document
3437
3486
 
3438
- owner = member.type === "admin" && role.type === "admin" && role.default === true
3439
- staff = member.type === "admin" && role.type === "admin"
3487
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
3488
+ staff = member.type === "admin" && role.type === "admin"
3440
3489
 
3441
3490
  `role.default` is the marker because it is the only property of a platform
3442
3491
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -1,41 +1,51 @@
1
1
  <template>
2
2
  <v-card width="100%" class="screen-modal passkey" :loading="processing">
3
3
  <div class="passkey__head">
4
- <v-card-title>{{ isEditMode ? "Edit" : "Assign" }} Pass &amp; Keys</v-card-title>
5
- <AppButton
6
- variant="icon"
7
- icon="mdi-close"
8
- aria-label="Close"
9
- @click="emit('close')"
10
- />
4
+ <v-card-title>{{ dialogTitle }}</v-card-title>
5
+ <v-btn icon="mdi-close" variant="text" aria-label="Close" @click="emit('close')" />
11
6
  </div>
12
7
 
13
8
  <div class="screen-modal__body passkey__body">
14
9
  <!-- `.filter-field` is the design's input, and it is a descendant
15
10
  selector, so both autocompletes get it from this one class. -->
16
11
  <v-row no-gutters class="ga-1 filter-field">
12
+ <!-- Whose passes and keys these are, as the return dialog. -->
17
13
  <v-col cols="12">
18
- <span class="passkey__name">Name: {{ prop.visitor.name }}</span>
14
+ <div class="passkey__who">
15
+ <div class="passkey__who-line font-weight-bold">
16
+ <v-icon icon="mdi-account-outline" size="18" />
17
+ {{ prop.visitor.name || "N/A" }}
18
+ </div>
19
+ <div class="passkey__who-line">
20
+ <v-icon icon="mdi-storefront-outline" size="18" />
21
+ {{ visitorLocation }}
22
+ </div>
23
+ </div>
19
24
  </v-col>
20
25
 
21
26
  <!-- Pass section -->
22
- <v-col cols="12" class="mt-3">
23
- <InputLabel title="Pass" />
27
+ <v-col v-if="showPass" cols="12" class="mt-3">
28
+ <p class="passkey__label">Pass</p>
24
29
 
25
30
  <!-- Existing pass -->
26
- <div v-if="existingPass" class="d-flex align-center ga-2 mb-2">
27
- <StatusChip tone="info" :dot="false">
28
- <v-icon icon="mdi-card-bulleted-outline" size="13" />
29
- {{ existingPass.prefixAndName }}
30
- </StatusChip>
31
- <span class="passkey__current">(current)</span>
32
- </div>
33
-
34
- <!-- New pass selector -->
31
+ <template v-if="existingPass">
32
+ <p class="passkey__sublabel">Current</p>
33
+ <div class="d-flex flex-wrap ga-2 mb-3">
34
+ <v-chip variant="outlined" prepend-icon="mdi-card-bulleted-outline"
35
+ class="font-weight-bold passkey-chip passkey-chip--pass">
36
+ {{ existingPass.prefixAndName }}
37
+ </v-chip>
38
+ </div>
39
+ <p class="passkey__sublabel">New pass</p>
40
+ </template>
41
+
42
+ <!-- New pass selector, with Scan Pass beside it (as iSecure). -->
43
+ <div class="scan-row">
35
44
  <v-autocomplete
36
45
  v-model="selectedPass"
46
+ class="scan-row__field"
37
47
  v-model:search="passInput"
38
- :label="existingPass?.keyId ? 'Change pass' : undefined"
48
+
39
49
  :hide-no-data="false"
40
50
  :items="passItems"
41
51
  item-title="prefixAndName"
@@ -61,49 +71,52 @@
61
71
  </v-list-item>
62
72
  </template>
63
73
  </v-autocomplete>
74
+ <v-btn variant="flat" class="text-none screen-btn-primary scan-row__btn" prepend-icon="mdi-qrcode"
75
+ data-test="passkey-scan-pass" :disabled="processing" @click="openScanner('pass')">
76
+ Scan Pass
77
+ </v-btn>
78
+ </div>
79
+ <p v-if="passNotice" :class="['passkey__notice', passNotice.error && 'passkey__notice--error']">
80
+ {{ passNotice.text }}
81
+ </p>
64
82
  </v-col>
65
83
 
66
84
  <!-- Keys section -->
67
85
  <v-col v-if="showKeys" cols="12" class="mt-3">
68
- <InputLabel title="Keys" />
86
+ <p class="passkey__label">Keys</p>
69
87
 
70
- <!-- Existing keys -->
71
- <div
72
- v-if="existingKeys.length > 0"
73
- class="d-flex flex-wrap ga-1 mb-2"
74
- >
75
- <!-- The design's tag chip, keeping the remove control the product
76
- already had: the tone and the shape are the shared ones, only
77
- the close button is drawn inside rather than by Vuetify. -->
78
- <StatusChip
79
- v-for="key in existingKeys.filter((k) => k.status != 'Removed')"
80
- :key="key.keyId"
81
- tone="warn"
82
- :dot="false"
83
- >
84
- <v-icon icon="mdi-key" size="13" />
85
- {{ key.prefixAndName }}
86
- <button
87
- class="passkey__remove"
88
- :aria-label="`Remove ${key.prefixAndName}`"
89
- @click="removeExistingKey(key)"
88
+ <!-- Existing keys: ✕ takes one off (saved as "Removed"). -->
89
+ <template v-if="existingKeys.some((k) => k.status != 'Removed')">
90
+ <p class="passkey__sublabel">Current</p>
91
+ <div class="d-flex flex-wrap ga-2 mb-3">
92
+ <v-chip
93
+ v-for="key in existingKeys.filter((k) => k.status != 'Removed')"
94
+ :key="key.keyId"
95
+ variant="outlined"
96
+ prepend-icon="mdi-key"
97
+ closable
98
+ :close-label="`Remove ${key.prefixAndName}`"
99
+ class="font-weight-bold passkey-chip passkey-chip--key"
100
+ @click:close="removeExistingKey(key)"
90
101
  >
91
- <v-icon icon="mdi-close-circle" size="14" />
92
- </button>
93
- </StatusChip>
94
- <span class="passkey__current align-self-center">(current)</span>
95
- </div>
96
-
97
- <!-- New keys selector -->
102
+ {{ key.prefixAndName }}
103
+ </v-chip>
104
+ </div>
105
+ <p class="passkey__sublabel">Add more keys</p>
106
+ </template>
107
+
108
+ <!-- New keys selector, with Scan Key beside it (as iSecure). -->
109
+ <div class="scan-row">
98
110
  <v-autocomplete
99
111
  v-model="selectedKeys"
112
+ class="scan-row__field"
100
113
  v-model:search="keyInput"
101
114
  :hide-no-data="false"
102
115
  :items="keyItems"
103
116
  item-title="prefixAndName"
104
117
  item-value="_id"
105
118
  :item-props="keyItemProps"
106
- :label="existingKeys.length > 0 ? 'Add more keys' : undefined"
119
+
107
120
  multiple
108
121
  variant="outlined"
109
122
  hide-details
@@ -127,6 +140,14 @@
127
140
  </v-list-item>
128
141
  </template>
129
142
  </v-autocomplete>
143
+ <v-btn variant="flat" class="text-none screen-btn-primary scan-row__btn" prepend-icon="mdi-qrcode"
144
+ data-test="passkey-scan-key" :disabled="processing" @click="openScanner('key')">
145
+ Scan Key
146
+ </v-btn>
147
+ </div>
148
+ <p v-if="keyNotice" :class="['passkey__notice', keyNotice.error && 'passkey__notice--error']">
149
+ {{ keyNotice.text }}
150
+ </p>
130
151
  </v-col>
131
152
  </v-row>
132
153
 
@@ -135,10 +156,13 @@
135
156
  </v-row>
136
157
  </div>
137
158
 
159
+ <VisitorPassKeyQRScanner v-if="scanner.open" :dialog="scanner.open" v-model:scanned-value="scanner.value"
160
+ @close-dialog="scanner.open = false" />
161
+
138
162
  <div class="passkey__footer">
139
163
  <AppButton variant="ghost" @click="emit('close')">Cancel</AppButton>
140
164
  <AppButton :disabled="!canSubmit" @click="handleSubmit">
141
- {{ isEditMode ? "Save" : "Assign" }}
165
+ {{ submitLabel }}
142
166
  </AppButton>
143
167
  </div>
144
168
  </v-card>
@@ -149,6 +173,7 @@ import type { PropType } from "vue";
149
173
  import usePassKey from "../composables/usePassKey";
150
174
  import useVisitor from "../composables/useVisitor";
151
175
  import useKeyPresses from "../composables/useKeyPresses";
176
+ import VisitorPassKeyQRScanner from "./VisitorPassKeyQRScanner.vue";
152
177
 
153
178
  const prop = defineProps({
154
179
  visitor: {
@@ -159,8 +184,13 @@ const prop = defineProps({
159
184
  type: String,
160
185
  required: true,
161
186
  },
187
+ /**
188
+ * iSecure's pass & key dialogs: "add" is Assign (Assign Pass & Keys for a
189
+ * contractor, Add Pass otherwise), "add-keys" is Add Key(s) (contractor),
190
+ * "edit-pass" is Change Pass, "edit" the combined edit.
191
+ */
162
192
  mode: {
163
- type: String as PropType<"add" | "edit">,
193
+ type: String as PropType<"add" | "edit" | "add-keys" | "edit-pass">,
164
194
  default: "add",
165
195
  },
166
196
  });
@@ -172,6 +202,17 @@ const emit = defineEmits<{
172
202
 
173
203
  const { getPassKeysByPageSearch } = usePassKey();
174
204
  const { updateVisitor } = useVisitor();
205
+ const { currentUser } = useLocalAuth();
206
+
207
+ const { formatLocation } = useSecurityUtils();
208
+ const visitorLocation = computed(
209
+ () =>
210
+ formatLocation({
211
+ block: (prop.visitor as any).block,
212
+ level: (prop.visitor as any).level,
213
+ unit: (prop.visitor as any).unit,
214
+ }) || "N/A"
215
+ );
175
216
 
176
217
  const processing = ref(false);
177
218
  const errorMessage = ref("");
@@ -195,8 +236,40 @@ const existingKeys = ref<
195
236
  { keyId: string; prefixAndName: string; status?: string }[]
196
237
  >([]);
197
238
 
198
- const isEditMode = computed(() => prop.mode === "edit");
199
- const showKeys = computed(() => prop.visitor.type === "contractor");
239
+ // Every mode but Assign starts from what the visitor holds now.
240
+ const isEditMode = computed(() => prop.mode !== "add");
241
+ const isContractor = computed(() => prop.visitor.type === "contractor");
242
+ const showPass = computed(() => prop.mode !== "add-keys");
243
+ const showKeys = computed(() => isContractor.value && prop.mode !== "edit-pass");
244
+
245
+ const dialogTitle = computed(() => {
246
+ switch (prop.mode) {
247
+ case "add-keys":
248
+ // Once the visitor holds keys the dialog adds AND removes them.
249
+ return prop.visitor.passKeys?.some((key: any) => key?.status != "Removed")
250
+ ? "Add/Remove Key(s)"
251
+ : "Add Key(s)";
252
+ case "edit-pass":
253
+ return "Change Pass";
254
+ case "edit":
255
+ return "Edit Pass & Keys";
256
+ default:
257
+ return isContractor.value ? "Assign Pass & Keys" : "Add Pass";
258
+ }
259
+ });
260
+
261
+ const submitLabel = computed(() => {
262
+ switch (prop.mode) {
263
+ case "add-keys":
264
+ return "Add";
265
+ case "edit-pass":
266
+ return "Update";
267
+ case "edit":
268
+ return "Save";
269
+ default:
270
+ return isContractor.value ? "Save" : "Add";
271
+ }
272
+ });
200
273
  // The key's press (if the site has any) under each key in the picker. Read
201
274
  // only when the key picker is shown.
202
275
  const { keyItemProps } = useKeyPresses(() => (showKeys.value ? prop.site : ""));
@@ -364,8 +437,136 @@ watch(
364
437
  { deep: true, immediate: true }
365
438
  );
366
439
 
440
+ /*
441
+ * Scan Pass / Scan Key (iSecure `onPassScanned` / `onScanned`): the QR holds
442
+ * the pass or key name (`prefixAndName`, e.g. VP01). It is looked up on the
443
+ * server across every status, so a pass or key that is not Available is
444
+ * reported with its status instead of "not found".
445
+ */
446
+ const PASS_TYPE_LABELS: Record<string, string> = {
447
+ "visitor-pass": "Visitor Pass",
448
+ "contractor-pass": "Contractor Pass",
449
+ "agent-pass": "Agent Pass",
450
+ "pass-key": "Key",
451
+ };
452
+
453
+ type TNotice = { text: string; error: boolean } | null;
454
+ const passNotice = ref<TNotice>(null);
455
+ const keyNotice = ref<TNotice>(null);
456
+ const scanner = reactive<{ open: boolean; value: string; target: "pass" | "key" }>({
457
+ open: false,
458
+ value: "",
459
+ target: "pass",
460
+ });
461
+
462
+ function openScanner(target: "pass" | "key") {
463
+ if (target === "pass") passNotice.value = null;
464
+ else keyNotice.value = null;
465
+ scanner.target = target;
466
+ scanner.value = "";
467
+ scanner.open = true;
468
+ }
469
+
470
+ type TScanned = { _id: string; prefixAndName?: string; status?: string; passType?: string };
471
+
472
+ async function findScanned(code: string): Promise<TScanned[]> {
473
+ const same = (item: TScanned) =>
474
+ (item.prefixAndName ?? "").trim().toLowerCase() === code.toLowerCase();
475
+ try {
476
+ const res: any = await getPassKeysByPageSearch({
477
+ search: code,
478
+ page: 1,
479
+ limit: 50,
480
+ sites: [prop.site],
481
+ });
482
+ const items: TScanned[] = res?.items ?? res?.data?.items ?? [];
483
+ return items.filter(same);
484
+ } catch {
485
+ // Offline / failed: the Available lists already loaded.
486
+ return [
487
+ ...((passesData.value as any)?.items ?? []),
488
+ ...((keysData.value as any)?.items ?? []),
489
+ ].filter(same);
490
+ }
491
+ }
492
+
493
+ async function onPassScanned(code: string) {
494
+ const fail = (text: string) => (passNotice.value = { text, error: true });
495
+ const found = await findScanned(code);
496
+ if (!found.length) return fail(`Pass or Key ${code} not found.`);
497
+ const item = found[0];
498
+ const isKey = item.passType === "pass-key";
499
+ const label = PASS_TYPE_LABELS[item.passType ?? ""] ?? (isKey ? "Key" : "Pass");
500
+ const name = item.prefixAndName ?? code;
501
+ if (found.length > 1) return fail(`${label} ${name} found duplicates.`);
502
+ if (isKey) return fail(`${label} ${name} is a key, not a pass.`);
503
+ if (item.status && item.status !== "Available") return fail(`${label} ${name} is ${item.status}.`);
504
+ if (existingPassId.value === item._id)
505
+ return fail(`${label} ${name} is already selected to this visitor.`);
506
+ if (!passTypesComputed.value.includes(item.passType ?? "")) {
507
+ const allowed = passTypesComputed.value
508
+ .map((t) => PASS_TYPE_LABELS[t] ?? t)
509
+ .join(" / ");
510
+ return fail(`${label} ${name} cannot be used for this visitor type (${allowed}).`);
511
+ }
512
+ // Shown in the picker even if the loaded list did not have it yet.
513
+ if (!passItems.value.some((p) => p._id === item._id)) {
514
+ passItems.value = [...passItems.value, item as TPassKey];
515
+ }
516
+ selectedPass.value = item._id;
517
+ passNotice.value = { text: `${label} ${name} selected.`, error: false };
518
+ }
519
+
520
+ async function onKeyScanned(code: string) {
521
+ const fail = (text: string) => (keyNotice.value = { text, error: true });
522
+ const found = await findScanned(code);
523
+ if (!found.length) return fail(`Pass or Key ${code} not found.`);
524
+ const item = found[0];
525
+ const isKey = item.passType === "pass-key";
526
+ const label = PASS_TYPE_LABELS[item.passType ?? ""] ?? (isKey ? "Key" : "Pass");
527
+ const name = item.prefixAndName ?? code;
528
+ if (found.length > 1) return fail(`${label} ${name} found duplicates.`);
529
+ if (!isKey) return fail(`You are scanning a key, but ${label} ${name} is a pass, not a key.`);
530
+ // A current key marked for removal: scanning it just undoes that.
531
+ if (existingKeys.value.some((k) => k.keyId === item._id && k.status === "Removed")) {
532
+ selectedKeys.value = [...selectedKeys.value, item._id];
533
+ selectKeys();
534
+ keyNotice.value = { text: `${label} ${name} restored.`, error: false };
535
+ return;
536
+ }
537
+ if (
538
+ selectedKeys.value.includes(item._id) ||
539
+ existingKeys.value.some((k) => k.keyId === item._id && k.status !== "Removed")
540
+ )
541
+ return fail(`${label} ${name} is already selected to this visitor.`);
542
+ if (item.status && item.status !== "Available") return fail(`${label} ${name} is ${item.status}.`);
543
+ if (!keyItems.value.some((k) => k._id === item._id)) {
544
+ keyItems.value = [...keyItems.value, item as TPassKey];
545
+ }
546
+ selectedKeys.value = [...selectedKeys.value, item._id];
547
+ keyNotice.value = { text: `${label} ${name} selected.`, error: false };
548
+ }
549
+
550
+ watch(
551
+ () => scanner.value,
552
+ (value) => {
553
+ const code = String(value ?? "").trim();
554
+ if (!code) return;
555
+ scanner.value = "";
556
+ if (scanner.target === "pass") void onPassScanned(code);
557
+ else void onKeyScanned(code);
558
+ }
559
+ );
560
+
561
+ // Picking by hand clears the last scan message.
562
+ watch(selectedPass, () => {
563
+ if (passNotice.value?.error) passNotice.value = null;
564
+ });
565
+
367
566
  const canSubmit = computed(() => {
368
567
  const hasRemovedKeys = existingKeys.value.some((k) => k.status === "Removed");
568
+ if (prop.mode === "edit-pass") return !!selectedPass.value;
569
+ if (prop.mode === "add-keys") return selectedKeys.value.length > 0 || hasRemovedKeys;
369
570
  return (
370
571
  !!selectedPass.value || selectedKeys.value.length > 0 || hasRemovedKeys
371
572
  );
@@ -394,7 +595,8 @@ async function handleSubmit() {
394
595
  add: true,
395
596
  });
396
597
  }
397
- payload.visitorPass = finalPasses;
598
+ // Add Key(s) changes the keys only (iSecure `addKeysPayload`).
599
+ if (showPass.value) payload.visitorPass = finalPasses;
398
600
 
399
601
  // Build final keys list: kept existing + newly selected
400
602
  if (showKeys.value) {
@@ -413,6 +615,7 @@ async function handleSubmit() {
413
615
  payload.passKeys = finalKeys;
414
616
  }
415
617
 
618
+ payload.updatedBy = currentUser.value?._id;
416
619
  await updateVisitor(prop.visitor._id, payload);
417
620
  emit("done");
418
621
  emit("close");
@@ -468,6 +671,81 @@ async function handleSubmit() {
468
671
  opacity: 1;
469
672
  }
470
673
 
674
+ /* A Scan button beside its input, the same height, as on the visitor form. */
675
+ .scan-row {
676
+ display: flex;
677
+ align-items: flex-start;
678
+ gap: 8px;
679
+ }
680
+
681
+ .scan-row__field {
682
+ flex: 1 1 auto;
683
+ min-width: 0;
684
+ }
685
+
686
+ .scan-row__btn {
687
+ flex: 0 0 auto;
688
+ }
689
+
690
+ .passkey__notice {
691
+ margin: 6px 0 0;
692
+ font-size: var(--fs-cell);
693
+ font-weight: 600;
694
+ color: rgb(var(--v-theme-success));
695
+ }
696
+
697
+ .passkey__notice--error {
698
+ color: var(--err);
699
+ }
700
+
701
+ .passkey__who {
702
+ display: flex;
703
+ flex-direction: column;
704
+ gap: 4px;
705
+ padding: 10px 12px;
706
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
707
+ border-radius: 8px;
708
+ font-size: 0.9375rem;
709
+ color: rgb(var(--v-theme-on-surface));
710
+ }
711
+
712
+ .passkey__who-line {
713
+ display: flex;
714
+ align-items: center;
715
+ gap: 8px;
716
+ }
717
+
718
+ .passkey__label {
719
+ margin: 6px 0 6px;
720
+ font-size: 1rem;
721
+ font-weight: 600;
722
+ color: rgb(var(--v-theme-on-surface));
723
+ }
724
+
725
+ .passkey__sublabel {
726
+ margin: 0 0 6px;
727
+ font-size: var(--fs-cell);
728
+ color: var(--text2);
729
+ }
730
+
731
+ /* The same pass / key chips as the visitor list and the return dialog. */
732
+ .passkey-chip.v-chip {
733
+ --v-chip-height: 32px;
734
+ font-size: 0.875rem;
735
+ padding: 0 12px;
736
+ background: rgb(var(--v-theme-surface));
737
+ color: rgb(var(--v-theme-on-surface));
738
+ border: 1.5px solid rgba(var(--v-theme-on-surface), 0.87);
739
+ }
740
+
741
+ .passkey-chip--pass :deep(.v-chip__prepend .v-icon) {
742
+ color: #1565c0;
743
+ }
744
+
745
+ .passkey-chip--key :deep(.v-chip__prepend .v-icon) {
746
+ color: #e65100;
747
+ }
748
+
471
749
  .passkey__footer {
472
750
  display: flex;
473
751
  justify-content: flex-end;
@@ -288,6 +288,10 @@ const removeFile = (index) => {
288
288
  onUnmounted(() => {
289
289
  stopCamera();
290
290
  });
291
+
292
+ // Lets a screen open the camera from its own button (the visitor form's
293
+ // "Camera") while the picker itself stays hidden.
294
+ defineExpose({ openCameraDialog });
291
295
  </script>
292
296
 
293
297
  <style scoped>