@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.
- package/CHANGELOG.md +12 -0
- package/components/AccessCardQrTagging.vue +314 -34
- package/components/AccessCardQrTaggingPrintQr.vue +75 -0
- package/components/{AddSupplyForm.vue → AddEqupmentForm.vue} +5 -5
- package/components/AreaChecklistHistoryLogs.vue +9 -0
- package/components/BuildingForm.vue +36 -5
- package/components/BuildingManagement/buildings.vue +18 -9
- package/components/BuildingManagement/units.vue +13 -115
- package/components/BuildingUnitFormAdd.vue +42 -33
- package/components/BuildingUnitFormEdit.vue +334 -139
- package/components/CleaningScheduleMain.vue +60 -13
- package/components/Dialog/DeleteConfirmation.vue +2 -2
- package/components/Dialog/UpdateMoreAction.vue +2 -2
- package/components/EntryPassInformation.vue +443 -0
- package/components/{CheckoutItemMain.vue → EquipmentItemMain.vue} +88 -85
- package/components/{SupplyManagement.vue → EquipmentManagement.vue} +3 -3
- package/components/Input/DateTimePicker.vue +17 -11
- package/components/Input/InputPhoneNumberV2.vue +8 -0
- package/components/ManageChecklistMain.vue +400 -36
- package/components/ScheduleAreaMain.vue +56 -0
- package/components/TableHygiene.vue +47 -430
- package/components/UnitPersonCard.vue +123 -0
- package/components/VehicleAddSelection.vue +2 -2
- package/components/VehicleForm.vue +78 -19
- package/components/VehicleManagement.vue +164 -40
- package/components/VisitorForm.vue +95 -20
- package/components/VisitorFormSelection.vue +13 -2
- package/components/VisitorManagement.vue +83 -55
- package/composables/useAccessManagement.ts +52 -0
- package/composables/useCleaningPermission.ts +7 -7
- package/composables/useDashboardData.ts +2 -2
- package/composables/{useSupply.ts → useEquipment.ts} +11 -11
- package/composables/{useCheckout.ts → useEquipmentItem.ts} +7 -7
- package/composables/{useCheckoutPermission.ts → useEquipmentItemPermission.ts} +13 -13
- package/composables/useEquipmentManagementPermission.ts +96 -0
- package/composables/{useSupplyPermission.ts → useEquipmentPermission.ts} +9 -9
- package/composables/usePeople.ts +4 -3
- package/composables/useVehicle.ts +35 -2
- package/composables/useVisitor.ts +3 -3
- package/composables/useWorkOrder.ts +25 -3
- package/package.json +3 -2
- package/types/building.d.ts +1 -1
- package/types/cleaner-schedule.d.ts +1 -0
- package/types/{checkout-item.d.ts → equipment-item.d.ts} +3 -3
- package/types/{supply.d.ts → equipment.d.ts} +2 -2
- package/types/html2pdf.d.ts +19 -0
- package/types/people.d.ts +5 -2
- package/types/site.d.ts +8 -0
- package/types/vehicle.d.ts +4 -3
- package/types/visitor.d.ts +2 -1
- package/.playground/app.vue +0 -41
- package/.playground/eslint.config.mjs +0 -6
- package/.playground/nuxt.config.ts +0 -22
- 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
|
|
61
|
-
|
|
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
|
-
|
|
200
|
+
closeIn: Date | string | number | null | undefined
|
|
195
201
|
) {
|
|
196
|
-
if (!
|
|
197
|
-
const _date = new Date(
|
|
202
|
+
if (!closeIn) return -1;
|
|
203
|
+
const _date = new Date(closeIn);
|
|
198
204
|
if (isNaN(_date.getTime())) return -1;
|
|
199
|
-
const
|
|
200
|
-
|
|
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.
|
|
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
|
-
<
|
|
6
|
+
<div v-if="message" class="text-error mt-2">
|
|
7
7
|
{{ message }} Do you want to delete anyway?
|
|
8
|
-
</
|
|
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>
|