@7365admin1/layer-common 4.97.1-staging.586 → 4.97.1-staging.588

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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>