@7365admin1/layer-common 1.10.6 → 1.10.8

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/components/AccessCardQrTagging.vue +314 -34
  3. package/components/AccessCardQrTaggingPrintQr.vue +75 -0
  4. package/components/{AddSupplyForm.vue → AddEqupmentForm.vue} +5 -5
  5. package/components/AreaChecklistHistoryLogs.vue +9 -0
  6. package/components/BuildingForm.vue +36 -5
  7. package/components/BuildingManagement/buildings.vue +18 -9
  8. package/components/BuildingManagement/units.vue +13 -115
  9. package/components/BuildingUnitFormAdd.vue +42 -33
  10. package/components/BuildingUnitFormEdit.vue +334 -139
  11. package/components/CleaningScheduleMain.vue +60 -13
  12. package/components/Dialog/DeleteConfirmation.vue +2 -2
  13. package/components/Dialog/UpdateMoreAction.vue +2 -2
  14. package/components/EntryPassInformation.vue +443 -0
  15. package/components/{CheckoutItemMain.vue → EquipmentItemMain.vue} +88 -85
  16. package/components/{SupplyManagement.vue → EquipmentManagement.vue} +3 -3
  17. package/components/Input/DateTimePicker.vue +17 -11
  18. package/components/Input/InputPhoneNumberV2.vue +8 -0
  19. package/components/ManageChecklistMain.vue +400 -36
  20. package/components/ScheduleAreaMain.vue +56 -0
  21. package/components/TableHygiene.vue +47 -430
  22. package/components/UnitPersonCard.vue +123 -0
  23. package/components/VehicleAddSelection.vue +2 -2
  24. package/components/VehicleForm.vue +78 -19
  25. package/components/VehicleManagement.vue +164 -40
  26. package/components/VisitorForm.vue +95 -20
  27. package/components/VisitorFormSelection.vue +13 -2
  28. package/components/VisitorManagement.vue +83 -55
  29. package/composables/useAccessManagement.ts +52 -0
  30. package/composables/useCleaningPermission.ts +7 -7
  31. package/composables/useDashboardData.ts +2 -2
  32. package/composables/{useSupply.ts → useEquipment.ts} +11 -11
  33. package/composables/{useCheckout.ts → useEquipmentItem.ts} +7 -7
  34. package/composables/{useCheckoutPermission.ts → useEquipmentItemPermission.ts} +13 -13
  35. package/composables/useEquipmentManagementPermission.ts +96 -0
  36. package/composables/{useSupplyPermission.ts → useEquipmentPermission.ts} +9 -9
  37. package/composables/usePeople.ts +4 -3
  38. package/composables/useVehicle.ts +35 -2
  39. package/composables/useVisitor.ts +3 -3
  40. package/composables/useWorkOrder.ts +25 -3
  41. package/package.json +3 -2
  42. package/types/building.d.ts +1 -1
  43. package/types/cleaner-schedule.d.ts +1 -0
  44. package/types/{checkout-item.d.ts → equipment-item.d.ts} +3 -3
  45. package/types/{supply.d.ts → equipment.d.ts} +2 -2
  46. package/types/html2pdf.d.ts +19 -0
  47. package/types/people.d.ts +5 -2
  48. package/types/site.d.ts +8 -0
  49. package/types/vehicle.d.ts +4 -3
  50. package/types/visitor.d.ts +2 -1
  51. package/.playground/app.vue +0 -41
  52. package/.playground/eslint.config.mjs +0 -6
  53. package/.playground/nuxt.config.ts +0 -22
  54. package/.playground/pages/feedback.vue +0 -30
@@ -57,8 +57,13 @@
57
57
  }}
58
58
  </template>
59
59
  <template #item.closeIn="{ item }">
60
- <v-chip class="text-capitalize" variant="flat" color="primary" pill>
61
- {{ remainingTime[item._id] || "No Status" }}
60
+ <v-chip
61
+ class="text-capitalize"
62
+ variant="flat"
63
+ :color="getCloseInColor(item._id)"
64
+ pill
65
+ >
66
+ {{ remainingTime[item._id] || "00h 00m" }}
62
67
  </v-chip>
63
68
  </template>
64
69
  <template #item.status="{ value }">
@@ -99,6 +104,7 @@
99
104
  <script lang="ts" setup>
100
105
  import { useCleaningSchedulePermission } from "../composables/useCleaningSchedulePermission";
101
106
  import useCleaningSchedules from "../composables/useCleaningSchedules";
107
+ import useUtils from "../composables/useUtils";
102
108
 
103
109
  const props = defineProps({
104
110
  orgId: { type: String, required: true },
@@ -191,18 +197,13 @@ const {
191
197
  );
192
198
 
193
199
  function calculateRemainingTime(
194
- item: Date | string | number | null | undefined
200
+ closeIn: Date | string | number | null | undefined
195
201
  ) {
196
- if (!item) return -1;
197
- const _date = new Date(item);
202
+ if (!closeIn) return -1;
203
+ const _date = new Date(closeIn);
198
204
  if (isNaN(_date.getTime())) return -1;
199
- const creationTime = _date.getTime();
200
- const currentTime = Date.now();
201
- const differenceInMillis = currentTime - creationTime;
202
- const differenceInSeconds = Math.floor(differenceInMillis / 1000);
203
- const desiredDurationInSeconds = 24 * 60 * 60;
204
- const remainingTimeInSeconds = desiredDurationInSeconds - differenceInSeconds;
205
- return remainingTimeInSeconds;
205
+ const remainingMs = _date.getTime() - Date.now();
206
+ return Math.floor(remainingMs / 1000);
206
207
  }
207
208
 
208
209
  const formatTime = (seconds: number) => {
@@ -218,7 +219,7 @@ const formatTime = (seconds: number) => {
218
219
  const updateRemainingTime = () => {
219
220
  items.value.forEach((item) => {
220
221
  const itemId = item._id as string;
221
- const _time = calculateRemainingTime(item.date as string);
222
+ const _time = calculateRemainingTime(item.closeIn as string);
222
223
  remainingSeconds.value[itemId] = _time;
223
224
  remainingTime.value[itemId] = _time <= 0 ? "00h 00m" : formatTime(_time);
224
225
  });
@@ -234,6 +235,41 @@ watchEffect(() => {
234
235
  updateRemainingTime();
235
236
  });
236
237
 
238
+ let _countdownInterval: ReturnType<typeof setInterval> | null = null;
239
+
240
+ onMounted(() => {
241
+ _countdownInterval = setInterval(updateRemainingTime, 60_000);
242
+
243
+ try {
244
+ const { $socket } = useNuxtApp() as any;
245
+ if ($socket) {
246
+ $socket.emit("join:cleaning-schedule", props.site);
247
+
248
+ $socket.on("cleaning-schedule:updated", (payload: { siteId: string }) => {
249
+ if (payload?.siteId !== props.site) return;
250
+ getCleanerChecklistRefresh();
251
+ });
252
+
253
+ $socket.on("cleaning-schedule:expired", () => {
254
+ getCleanerChecklistRefresh();
255
+ });
256
+ }
257
+ } catch (_) {}
258
+ });
259
+
260
+ onUnmounted(() => {
261
+ if (_countdownInterval) clearInterval(_countdownInterval);
262
+
263
+ try {
264
+ const { $socket } = useNuxtApp() as any;
265
+ if ($socket) {
266
+ $socket.emit("leave:cleaning-schedule", props.site);
267
+ $socket.off("cleaning-schedule:updated");
268
+ $socket.off("cleaning-schedule:expired");
269
+ }
270
+ } catch (_) {}
271
+ });
272
+
237
273
  async function downloadItem(item: any) {
238
274
  const id = item?._id;
239
275
  if (!id) return;
@@ -248,6 +284,14 @@ async function downloadItem(item: any) {
248
284
  }
249
285
  }
250
286
 
287
+ const selectedScheduleStatus = useState<string>(
288
+ "selectedScheduleStatus",
289
+ () => ""
290
+ );
291
+
292
+ const isItemClosed = (item: any): boolean =>
293
+ String(item?.status ?? "").toLowerCase() === "closed";
294
+
251
295
  function onRowClick(data: any) {
252
296
  const item = data?.item ?? data;
253
297
  const id = item?._id || item?.id || item?.areaId;
@@ -260,6 +304,9 @@ function onRowClick(data: any) {
260
304
  return;
261
305
  }
262
306
 
307
+ // Store status so nested pages know whether the schedule is closed
308
+ selectedScheduleStatus.value = String(item?.status ?? "");
309
+
263
310
  if (props.type === "toilet") {
264
311
  const path = `/${props.orgId}/${props.site}/toilet-checklist/${id}`;
265
312
  navigateTo(path);
@@ -3,9 +3,9 @@
3
3
  <v-card-text style="max-height: 100vh; overflow-y: auto" class="pa-5 my-5 px-7 text-center">
4
4
  <span> {{ promptTitle }}</span>
5
5
 
6
- <span v-if="message" class="text-error mt-2">
6
+ <div v-if="message" class="text-error mt-2">
7
7
  {{ message }} Do you want to delete anyway?
8
- </span>
8
+ </div>
9
9
  </v-card-text>
10
10
 
11
11
  <v-toolbar class="pa-0" density="compact">
@@ -19,7 +19,7 @@
19
19
 
20
20
  <v-toolbar class="pa-0" density="compact">
21
21
  <v-row no-gutters>
22
- <v-col cols="6" class="pa-0">
22
+ <v-col :cols="(canUpdate || canDelete) ? 6 : 12" class="pa-0">
23
23
  <v-btn
24
24
  block
25
25
  variant="text"
@@ -32,7 +32,7 @@
32
32
  </v-btn>
33
33
  </v-col>
34
34
 
35
- <v-col cols="6" class="pa-0" >
35
+ <v-col v-if="canUpdate || canDelete" cols="6" class="pa-0" >
36
36
  <v-menu contained>
37
37
  <template #activator="{ props }">
38
38
  <v-btn
@@ -0,0 +1,443 @@
1
+ <template>
2
+ <div>
3
+ <v-skeleton-loader v-if="loading" type="list-item-two-line" width="100%" />
4
+ <template v-else>
5
+ <v-divider class="mb-4" />
6
+ <InputLabel title="Pass Type (Optional)" />
7
+ <v-row no-gutters class="ga-3 mt-1">
8
+ <v-col>
9
+ <v-card
10
+ :variant="passType === 'QR' ? 'tonal' : 'outlined'"
11
+ :color="passType === 'QR' ? 'primary' : undefined"
12
+ class="pa-3 d-flex flex-column align-center ga-2 cursor-pointer position-relative"
13
+ rounded="lg"
14
+ @click="passType = passType === 'QR' ? null : 'QR'"
15
+ >
16
+ <v-icon v-if="passType === 'QR'" size="18" color="primary" class="selected-check">mdi-check-circle</v-icon>
17
+ <v-icon size="32">mdi-qrcode</v-icon>
18
+ <span class="text-subtitle-2 font-weight-bold">QR Pass</span>
19
+ </v-card>
20
+ </v-col>
21
+ <v-col v-if="nfcEnabled">
22
+ <v-card
23
+ :variant="passType === 'NFC' ? 'tonal' : 'outlined'"
24
+ :color="passType === 'NFC' ? 'primary' : undefined"
25
+ class="pa-3 d-flex flex-column align-center ga-2 cursor-pointer position-relative"
26
+ rounded="lg"
27
+ @click="passType = passType === 'NFC' ? null : 'NFC'"
28
+ >
29
+ <v-icon v-if="passType === 'NFC'" size="18" color="primary" class="selected-check">mdi-check-circle</v-icon>
30
+ <v-icon size="32">mdi-nfc</v-icon>
31
+ <span class="text-subtitle-2 font-weight-bold">NFC</span>
32
+ </v-card>
33
+ </v-col>
34
+ </v-row>
35
+
36
+ <!-- QR Pass section -->
37
+ <template v-if="passType === 'QR'">
38
+ <v-alert
39
+ v-if="!hasPrinter"
40
+ type="warning"
41
+ variant="tonal"
42
+ density="compact"
43
+ class="mt-3"
44
+ icon="mdi-printer-alert"
45
+ >
46
+ No printer is configured in the settings. QR Pass may not print.
47
+ </v-alert>
48
+ <template v-else>
49
+ <InputLabel title="Quantity" class="mt-3" />
50
+ <v-text-field
51
+ v-model.number="quantity"
52
+ type="number"
53
+ density="comfortable"
54
+ :min="1"
55
+ hide-details
56
+ />
57
+ </template>
58
+ </template>
59
+
60
+ <!-- NFC Pass section -->
61
+ <template v-if="passType === 'NFC'">
62
+ <div class="mt-3">
63
+ <InputLabel title="Select Cards" />
64
+ <v-select
65
+ v-model="selectedCards"
66
+ :items="cardItems"
67
+ :loading="cardsLoading"
68
+ item-title="cardNumber"
69
+ item-value="_id"
70
+ density="comfortable"
71
+ multiple
72
+ chips
73
+ closable-chips
74
+ hide-details
75
+ placeholder="Select cards..."
76
+ return-object
77
+ >
78
+ <template #item="{ props: itemProps, item }">
79
+ <v-list-item v-bind="itemProps">
80
+ <template #subtitle>
81
+ <span class="text-caption text-grey">{{ item.raw.cardNumber }}</span>
82
+ </template>
83
+ </v-list-item>
84
+ </template>
85
+ </v-select>
86
+ </div>
87
+
88
+ <div class="mt-3">
89
+ <InputLabel title="Quantity" />
90
+ <v-text-field
91
+ :model-value="selectedCards.length"
92
+ type="number"
93
+ density="comfortable"
94
+ disabled
95
+ hide-details
96
+ />
97
+ </div>
98
+
99
+ <v-row no-gutters class="ga-3 mt-3">
100
+ <v-col>
101
+ <v-btn
102
+ :color="isBarcodeScanningActive ? 'success' : 'primary'"
103
+ variant="outlined"
104
+ block
105
+ prepend-icon="mdi-barcode-scan"
106
+ @click="toggleBarcodeScanning"
107
+ >
108
+ <v-icon v-if="isBarcodeScanningActive" start>mdi-check-circle</v-icon>
109
+ {{ isBarcodeScanningActive ? 'Scanning...' : 'Scan Via BarCode' }}
110
+ </v-btn>
111
+ </v-col>
112
+ <v-col>
113
+ <v-btn
114
+ variant="outlined"
115
+ color="primary"
116
+ block
117
+ prepend-icon="mdi-camera"
118
+ @click="openCameraDialog"
119
+ >
120
+ Scan with Camera
121
+ </v-btn>
122
+ </v-col>
123
+ </v-row>
124
+
125
+ <v-alert
126
+ v-if="isBarcodeScanningActive"
127
+ type="info"
128
+ variant="tonal"
129
+ density="compact"
130
+ class="mt-3 text-center"
131
+ >
132
+ <v-icon class="mr-2 animate-pulse">mdi-radar</v-icon>
133
+ Ready to scan NFC cards. Please scan a card with your barcode scanner.
134
+ </v-alert>
135
+ </template>
136
+ </template>
137
+ <v-divider class="mt-6" />
138
+
139
+ <!-- Hidden canvas used for frame-by-frame barcode detection -->
140
+ <canvas ref="canvasRef" style="display: none" />
141
+
142
+ <!-- Camera scanner dialog -->
143
+ <v-dialog
144
+ v-model="showCameraDialog"
145
+ width="700"
146
+ max-width="700"
147
+ persistent
148
+ transition="dialog-bottom-transition"
149
+ @after-enter="startCamera"
150
+ @after-leave="stopCamera"
151
+ >
152
+ <v-container class="d-flex justify-center" style="max-height: 90vh">
153
+ <v-card elevation="2" class="d-flex flex-column align-center pa-2 w-100">
154
+ <v-toolbar color="transparent">
155
+ <v-card-title class="text-h5">Scan Card</v-card-title>
156
+ <v-spacer />
157
+ <v-btn icon="mdi-close" color="grey-darken-1" @click="closeCameraDialog" />
158
+ </v-toolbar>
159
+
160
+ <div class="position-relative w-100" style="aspect-ratio: 4/3; background: #000">
161
+ <video
162
+ ref="videoRef"
163
+ autoplay
164
+ playsinline
165
+ muted
166
+ class="w-100 h-100"
167
+ style="object-fit: cover"
168
+ />
169
+ <!-- scan overlay -->
170
+ <div class="scan-overlay">
171
+ <div class="scan-box" />
172
+ </div>
173
+ </div>
174
+
175
+ <div v-if="cameraError" class="pa-3 text-center text-error text-caption">
176
+ {{ cameraError }}
177
+ </div>
178
+
179
+ <v-card-actions class="justify-center">
180
+ <v-btn icon="mdi-camera-switch" color="primary" @click="switchCamera" />
181
+ </v-card-actions>
182
+ </v-card>
183
+ </v-container>
184
+ </v-dialog>
185
+ </div>
186
+ </template>
187
+
188
+ <script setup lang="ts">
189
+ const props = defineProps({
190
+ settings: {
191
+ type: Object as PropType<Record<string, any> | null>,
192
+ default: null,
193
+ },
194
+ loading: {
195
+ type: Boolean,
196
+ default: false,
197
+ },
198
+ modelValue: {
199
+ type: String as PropType<string | null>,
200
+ default: null,
201
+ },
202
+ quantity: {
203
+ type: Number as PropType<number | null>,
204
+ default: null,
205
+ },
206
+ cards: {
207
+ type: Array as PropType<any[]>,
208
+ default: () => [],
209
+ },
210
+ });
211
+
212
+ const emit = defineEmits(["update:modelValue", "update:quantity", "update:cards"]);
213
+
214
+ const { getAll: getAllCards } = useCard();
215
+
216
+ const nfcEnabled = computed(() => props.settings?.data?.settings?.nfcPass ?? false);
217
+ const printer = computed(() => props.settings?.data?.settings?.printer ?? { vendorId: null, productId: null });
218
+ const hasPrinter = computed(() => !!printer.value.vendorId && !!printer.value.productId);
219
+
220
+ const passType = computed({
221
+ get: () => props.modelValue,
222
+ set: (val) => emit("update:modelValue", val),
223
+ });
224
+
225
+ const quantity = computed({
226
+ get: () => props.quantity,
227
+ set: (val) => emit("update:quantity", val),
228
+ });
229
+
230
+ const selectedCards = computed({
231
+ get: () => props.cards,
232
+ set: (val) => {
233
+ emit("update:cards", val);
234
+ emit("update:quantity", val.length || null);
235
+ },
236
+ });
237
+
238
+ // ─── Card fetching ────────────────────────────────────────────────
239
+ const cardItems = ref<any[]>([]);
240
+ const cardsLoading = ref(false);
241
+
242
+ async function fetchCards() {
243
+ cardsLoading.value = true;
244
+ try {
245
+ const res = await getAllCards({ assignFilter: "available", limit: 100 });
246
+ cardItems.value = res?.data ?? [];
247
+ } finally {
248
+ cardsLoading.value = false;
249
+ }
250
+ }
251
+
252
+ function addCard(card: any) {
253
+ const already = selectedCards.value.some((c) => c._id === card._id);
254
+ if (!already) selectedCards.value = [...selectedCards.value, card];
255
+ }
256
+
257
+ // ─── Barcode scanning ─────────────────────────────────────────────
258
+ const isBarcodeScanningActive = ref(false);
259
+ const barcodeBuffer = ref("");
260
+ const barcodeTimeout = ref<ReturnType<typeof setTimeout> | null>(null);
261
+
262
+ const toggleBarcodeScanning = () => {
263
+ isBarcodeScanningActive.value = !isBarcodeScanningActive.value;
264
+ if (!isBarcodeScanningActive.value) {
265
+ barcodeBuffer.value = "";
266
+ if (barcodeTimeout.value) clearTimeout(barcodeTimeout.value);
267
+ }
268
+ };
269
+
270
+ const validateScannedBarcode = (scannedCode: string) => {
271
+ const card = cardItems.value.find((c) => c.cardNumber === scannedCode);
272
+ if (card) addCard(card);
273
+ barcodeBuffer.value = "";
274
+ };
275
+
276
+ const handleBarcodeInput = (event: KeyboardEvent) => {
277
+ if (!isBarcodeScanningActive.value || passType.value !== "NFC") return;
278
+
279
+ if (event.key === "Enter") {
280
+ event.preventDefault();
281
+ if (barcodeTimeout.value) clearTimeout(barcodeTimeout.value);
282
+ if (barcodeBuffer.value.trim()) validateScannedBarcode(barcodeBuffer.value.trim());
283
+ return;
284
+ }
285
+
286
+ if (event.key.length === 1) {
287
+ barcodeBuffer.value += event.key;
288
+ if (barcodeTimeout.value) clearTimeout(barcodeTimeout.value);
289
+ barcodeTimeout.value = setTimeout(() => { barcodeBuffer.value = ""; }, 200);
290
+ }
291
+ };
292
+
293
+ // ─── Camera scanner ───────────────────────────────────────────────
294
+ const videoRef = ref<HTMLVideoElement | null>(null);
295
+ const canvasRef = ref<HTMLCanvasElement | null>(null);
296
+ const showCameraDialog = ref(false);
297
+ const cameraError = ref("");
298
+ const cameraFacingMode = ref<"environment" | "user">("environment");
299
+ let mediaStream: MediaStream | null = null;
300
+ let scanIntervalId: ReturnType<typeof setInterval> | null = null;
301
+ let barcodeDetector: any = null;
302
+
303
+ const openCameraDialog = () => {
304
+ cameraError.value = "";
305
+ showCameraDialog.value = true;
306
+ };
307
+
308
+ const closeCameraDialog = () => {
309
+ showCameraDialog.value = false;
310
+ };
311
+
312
+ const startCamera = async () => {
313
+ cameraError.value = "";
314
+
315
+ if (!("BarcodeDetector" in window)) {
316
+ cameraError.value = "BarcodeDetector is not supported in this browser. Use Chrome or Edge.";
317
+ return;
318
+ }
319
+
320
+ try {
321
+ mediaStream = await navigator.mediaDevices.getUserMedia({
322
+ video: { facingMode: cameraFacingMode.value, width: { ideal: 1280 }, height: { ideal: 720 } },
323
+ });
324
+
325
+ if (videoRef.value) {
326
+ videoRef.value.srcObject = mediaStream;
327
+ await videoRef.value.play();
328
+ }
329
+
330
+ barcodeDetector = new (window as any).BarcodeDetector({
331
+ formats: ["qr_code", "code_128", "code_39", "ean_13", "ean_8"],
332
+ });
333
+
334
+ // Wait a tick for the video to have real frame data before scanning
335
+ await new Promise((resolve) => setTimeout(resolve, 500));
336
+ startScanLoop();
337
+ } catch (err: any) {
338
+ cameraError.value = `Camera error: ${err.message}`;
339
+ }
340
+ };
341
+
342
+ const startScanLoop = () => {
343
+ if (scanIntervalId !== null) clearInterval(scanIntervalId);
344
+ scanIntervalId = setInterval(scanFrame, 300);
345
+ };
346
+
347
+ const scanFrame = async () => {
348
+ const video = videoRef.value;
349
+ const canvas = canvasRef.value;
350
+ if (!video || !canvas || !barcodeDetector || !showCameraDialog.value) return;
351
+ if (video.readyState < 2 || video.videoWidth === 0) return;
352
+
353
+ const ctx = canvas.getContext("2d");
354
+ if (!ctx) return;
355
+
356
+ canvas.width = video.videoWidth;
357
+ canvas.height = video.videoHeight;
358
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
359
+
360
+ try {
361
+ const barcodes = await barcodeDetector.detect(canvas);
362
+ for (const barcode of barcodes) {
363
+ const card = cardItems.value.find((c) => c.cardNumber === barcode.rawValue);
364
+ if (card) {
365
+ addCard(card);
366
+ closeCameraDialog();
367
+ return;
368
+ }
369
+ }
370
+ } catch {
371
+ // silent — detector throws on blank frames
372
+ }
373
+ };
374
+
375
+ const stopCamera = () => {
376
+ if (scanIntervalId !== null) { clearInterval(scanIntervalId); scanIntervalId = null; }
377
+ if (mediaStream) { mediaStream.getTracks().forEach((t) => t.stop()); mediaStream = null; }
378
+ if (videoRef.value) videoRef.value.srcObject = null;
379
+ barcodeDetector = null;
380
+ };
381
+
382
+ const switchCamera = async () => {
383
+ stopCamera();
384
+ cameraFacingMode.value = cameraFacingMode.value === "environment" ? "user" : "environment";
385
+ await startCamera();
386
+ };
387
+
388
+ // ─── Watchers & lifecycle ─────────────────────────────────────────
389
+ watch(
390
+ () => passType.value,
391
+ (val) => {
392
+ if (val === "NFC") {
393
+ fetchCards();
394
+ window.addEventListener("keydown", handleBarcodeInput);
395
+ } else {
396
+ isBarcodeScanningActive.value = false;
397
+ barcodeBuffer.value = "";
398
+ if (barcodeTimeout.value) clearTimeout(barcodeTimeout.value);
399
+ window.removeEventListener("keydown", handleBarcodeInput);
400
+ }
401
+ }
402
+ );
403
+
404
+ onUnmounted(() => {
405
+ window.removeEventListener("keydown", handleBarcodeInput);
406
+ if (barcodeTimeout.value) clearTimeout(barcodeTimeout.value);
407
+ stopCamera();
408
+ });
409
+ </script>
410
+
411
+ <style scoped>
412
+ .selected-check {
413
+ position: absolute;
414
+ top: 6px;
415
+ right: 6px;
416
+ }
417
+
418
+ .animate-pulse {
419
+ animation: pulse 1.5s ease-in-out infinite;
420
+ }
421
+
422
+ @keyframes pulse {
423
+ 0%, 100% { opacity: 1; }
424
+ 50% { opacity: 0.3; }
425
+ }
426
+
427
+ .scan-overlay {
428
+ position: absolute;
429
+ inset: 0;
430
+ display: flex;
431
+ align-items: center;
432
+ justify-content: center;
433
+ pointer-events: none;
434
+ }
435
+
436
+ .scan-box {
437
+ width: 220px;
438
+ height: 220px;
439
+ border: 3px solid rgba(255, 255, 255, 0.85);
440
+ border-radius: 12px;
441
+ box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
442
+ }
443
+ </style>