@7365admin1/layer-common 1.10.0 → 1.10.1

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 (81) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/components/AcceptDialog.vue +44 -0
  3. package/components/AccessCardAddForm.vue +101 -13
  4. package/components/AccessManagement.vue +130 -47
  5. package/components/AddSupplyForm.vue +165 -0
  6. package/components/AreaChecklistHistoryLogs.vue +235 -0
  7. package/components/AreaChecklistHistoryMain.vue +176 -0
  8. package/components/AreaFormDialog.vue +266 -0
  9. package/components/AreaMain.vue +841 -0
  10. package/components/AttendanceCheckInOutDialog.vue +416 -0
  11. package/components/AttendanceDetailsDialog.vue +184 -0
  12. package/components/AttendanceMain.vue +155 -0
  13. package/components/AttendanceMapSearchDialog.vue +393 -0
  14. package/components/AttendanceSettingsDialog.vue +398 -0
  15. package/components/BuildingManagement/buildings.vue +5 -5
  16. package/components/BuildingManagement/units.vue +5 -5
  17. package/components/ChecklistItemRow.vue +54 -0
  18. package/components/CheckoutItemMain.vue +705 -0
  19. package/components/CleaningScheduleMain.vue +271 -0
  20. package/components/DocumentManagement.vue +4 -0
  21. package/components/EntryPass/QrTemplatePreview.vue +104 -0
  22. package/components/EntryPassMain.vue +252 -200
  23. package/components/HygieneUpdateMoreAction.vue +238 -0
  24. package/components/ManageChecklistMain.vue +384 -0
  25. package/components/MemberMain.vue +48 -20
  26. package/components/MyAttendanceMain.vue +224 -0
  27. package/components/OnlineFormsConfiguration.vue +9 -2
  28. package/components/PhotoUpload.vue +410 -0
  29. package/components/ScheduleAreaMain.vue +313 -0
  30. package/components/ScheduleTaskAreaFormDialog.vue +144 -0
  31. package/components/ScheduleTaskAreaUpdateMoreAction.vue +109 -0
  32. package/components/ScheduleTaskForm.vue +471 -0
  33. package/components/ScheduleTaskMain.vue +345 -0
  34. package/components/ScheduleTastTicketMain.vue +182 -0
  35. package/components/StockCard.vue +191 -0
  36. package/components/SupplyManagementMain.vue +557 -0
  37. package/components/TableHygiene.vue +617 -0
  38. package/components/UnitMain.vue +451 -0
  39. package/components/VisitorManagement.vue +28 -15
  40. package/composables/useAccessManagement.ts +90 -0
  41. package/composables/useAreaPermission.ts +51 -0
  42. package/composables/useAreas.ts +99 -0
  43. package/composables/useAttendance.ts +89 -0
  44. package/composables/useAttendancePermission.ts +68 -0
  45. package/composables/useBuilding.ts +2 -2
  46. package/composables/useBuildingUnit.ts +2 -2
  47. package/composables/useCard.ts +2 -0
  48. package/composables/useCheckout.ts +61 -0
  49. package/composables/useCheckoutPermission.ts +80 -0
  50. package/composables/useCleaningPermission.ts +229 -0
  51. package/composables/useCleaningSchedulePermission.ts +58 -0
  52. package/composables/useCleaningSchedules.ts +233 -0
  53. package/composables/useCountry.ts +8 -0
  54. package/composables/useDashboardData.ts +2 -2
  55. package/composables/useFeedback.ts +1 -1
  56. package/composables/useLocation.ts +78 -0
  57. package/composables/useOnlineForm.ts +16 -9
  58. package/composables/usePeople.ts +87 -72
  59. package/composables/useQR.ts +29 -0
  60. package/composables/useScheduleTask.ts +89 -0
  61. package/composables/useScheduleTaskArea.ts +85 -0
  62. package/composables/useScheduleTaskPermission.ts +68 -0
  63. package/composables/useSiteEntryPassSettings.ts +4 -15
  64. package/composables/useStock.ts +45 -0
  65. package/composables/useSupply.ts +63 -0
  66. package/composables/useSupplyPermission.ts +92 -0
  67. package/composables/useUnitPermission.ts +51 -0
  68. package/composables/useUnits.ts +82 -0
  69. package/composables/useWebUsb.ts +389 -0
  70. package/composables/useWorkOrder.ts +1 -1
  71. package/nuxt.config.ts +3 -0
  72. package/package.json +4 -1
  73. package/types/area.d.ts +22 -0
  74. package/types/attendance.d.ts +38 -0
  75. package/types/checkout-item.d.ts +27 -0
  76. package/types/cleaner-schedule.d.ts +54 -0
  77. package/types/location.d.ts +42 -0
  78. package/types/schedule-task.d.ts +18 -0
  79. package/types/stock.d.ts +16 -0
  80. package/types/supply.d.ts +11 -0
  81. package/utils/acm-crypto.ts +30 -0
@@ -0,0 +1,224 @@
1
+ <template>
2
+ <v-row no-gutters align="center" justify="center">
3
+ <v-col cols="12" lg="12">
4
+ <TableMain
5
+ title="Attendance Records"
6
+ :items="items"
7
+ :headers="headers"
8
+ :loading="loading"
9
+ :show-header="true"
10
+ v-model:page="page"
11
+ :pages="pages"
12
+ :pageRange="pageRange"
13
+ no-data-text="No attendance records found."
14
+ @row-click="handleRowClick"
15
+ >
16
+ <template #actions>
17
+ <v-row no-gutters align="center" class="w-100">
18
+ <v-col cols="auto" v-if="canCheckInOut">
19
+ <v-btn
20
+ class="text-none"
21
+ rounded="pill"
22
+ variant="tonal"
23
+ size="large"
24
+ @click="onCreateItem"
25
+ >
26
+ {{ hasCheckedInToday ? "Check Out" : "Check In" }}
27
+ </v-btn>
28
+ </v-col>
29
+ <v-spacer />
30
+
31
+ <v-col cols="auto">
32
+ <v-text-field
33
+ v-model="searchInput"
34
+ density="compact"
35
+ placeholder="Search"
36
+ clearable
37
+ width="300"
38
+ append-inner-icon="mdi-magnify"
39
+ hide-details
40
+ />
41
+ </v-col>
42
+ </v-row>
43
+ </template>
44
+
45
+ <template #item.checkInTimestamp="{ item }">
46
+ <span>{{ formatDate(item.checkInTimestamp) }}</span>
47
+ </template>
48
+
49
+ <template #item.checkOutTimestamp="{ item }">
50
+ <span>{{
51
+ item.checkOutTimestamp ? formatDate(item.checkOutTimestamp) : "N/A"
52
+ }}</span>
53
+ </template>
54
+
55
+ <template #item.totalHours="{ item }">
56
+ <span>{{ calculateTotalHours(item) }}</span>
57
+ </template>
58
+ </TableMain>
59
+ </v-col>
60
+ </v-row>
61
+
62
+ <AttendanceCheckInOutDialog
63
+ v-model="dialogShowForm"
64
+ :action="currentAction"
65
+ @saved="onAttendanceSaved"
66
+ @close="dialogShowForm = false"
67
+ />
68
+
69
+ <AttendanceDetailsDialog
70
+ v-model="dialogShowMoreActions"
71
+ :attendance-id="selectedAttendanceId"
72
+ @close="dialogShowMoreActions = false"
73
+ />
74
+
75
+ <Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
76
+ </template>
77
+
78
+ <script setup lang="ts">
79
+ import useAttendance from "../composables/useAttendance";
80
+ import { useAttendancePermission } from "../composables/useAttendancePermission";
81
+
82
+ const props = defineProps({
83
+ orgId: { type: String, default: "" },
84
+ site: { type: String, default: "" },
85
+ });
86
+
87
+ const { canViewOwnAttendance, canCheckInOut, canViewAttendanceDetails } =
88
+ useAttendancePermission();
89
+
90
+ const searchInput = ref("");
91
+ const dialogShowForm = ref(false);
92
+ const currentAction = ref<"checkIn" | "checkOut">("checkIn");
93
+ const messageSnackbar = ref(false);
94
+ const message = ref("");
95
+ const messageColor = ref("success");
96
+
97
+ const items = ref<Array<Record<string, any>>>([]);
98
+ const submitting = ref(false);
99
+
100
+ const headers = [
101
+ { title: "Team Member", value: "userName" },
102
+ { title: "Check In", value: "checkInTimestamp" },
103
+ { title: "Check Out", value: "checkOutTimestamp" },
104
+ { title: "Total Hours", value: "totalHours" },
105
+ ];
106
+
107
+ const { getMyAttendances, checkIn, checkOut } = useAttendance();
108
+ const { formatDate } = useUtils();
109
+
110
+ const dialogShowMoreActions = ref(false);
111
+ const selectedAttendanceId = ref("");
112
+ const todayAttendance = ref<Record<string, any> | null>(null);
113
+
114
+ const hasCheckedInToday = computed(() => {
115
+ return (
116
+ todayAttendance.value &&
117
+ todayAttendance.value.checkInTimestamp &&
118
+ !todayAttendance.value.checkOutTimestamp
119
+ );
120
+ });
121
+
122
+ function calculateTotalHours(attendance: any): string {
123
+ const checkInTime =
124
+ attendance.checkInTimestamp || attendance.checkIn?.timestamp;
125
+ const checkOutTime =
126
+ attendance.checkOutTimestamp || attendance.checkOut?.timestamp;
127
+
128
+ if (!checkInTime) return "N/A";
129
+ if (!checkOutTime) return "In Progress";
130
+
131
+ const checkIn = new Date(checkInTime);
132
+ const checkOut = new Date(checkOutTime);
133
+ const diffMs = checkOut.getTime() - checkIn.getTime();
134
+ const diffHours = diffMs / (1000 * 60 * 60);
135
+
136
+ return `${diffHours.toFixed(2)} hrs`;
137
+ }
138
+
139
+ const page = ref(1);
140
+ const pages = ref(0);
141
+ const pageRange = ref("-- - -- of --");
142
+
143
+ const {
144
+ data: getMyAttendancesReq,
145
+ refresh: getMyAttendancesRefresh,
146
+ pending: loading,
147
+ } = await useLazyAsyncData(
148
+ "get-my-attendances",
149
+ () =>
150
+ getMyAttendances({
151
+ page: page.value,
152
+ search: searchInput.value,
153
+ site: props.site,
154
+ }),
155
+ {
156
+ watch: [page, searchInput, () => props.site],
157
+ }
158
+ );
159
+
160
+ watchEffect(() => {
161
+ if (getMyAttendancesReq.value) {
162
+ items.value = getMyAttendancesReq.value.items;
163
+ pages.value = getMyAttendancesReq.value.pages;
164
+ pageRange.value = getMyAttendancesReq.value.pageRange;
165
+
166
+ const today = new Date().toISOString().split("T")[0];
167
+ todayAttendance.value =
168
+ getMyAttendancesReq.value.items.find((item: any) => {
169
+ if (!item.checkInTimestamp) return false;
170
+ const itemDate = new Date(item.checkInTimestamp)
171
+ .toISOString()
172
+ .split("T")[0];
173
+ return itemDate === today && !item.checkOutTimestamp;
174
+ }) || null;
175
+ }
176
+ });
177
+
178
+ const onCreateItem = () => {
179
+ currentAction.value = hasCheckedInToday.value ? "checkOut" : "checkIn";
180
+ dialogShowForm.value = true;
181
+ };
182
+
183
+ async function handleRowClick(data: any) {
184
+ const id = (data?.item as any)?._id;
185
+ if (!id) return;
186
+
187
+ selectedAttendanceId.value = id;
188
+ dialogShowMoreActions.value = true;
189
+ }
190
+
191
+ const onAttendanceSaved = async (
192
+ payload: TAttendanceCheckIn | TAttendanceCheckOut
193
+ ) => {
194
+ submitting.value = true;
195
+
196
+ try {
197
+ let response;
198
+ if (currentAction.value === "checkIn") {
199
+ response = await checkIn(props.site, payload as TAttendanceCheckIn);
200
+ } else {
201
+ if (!todayAttendance.value?._id) {
202
+ throw new Error("Attendance ID not found");
203
+ }
204
+ response = await checkOut(
205
+ todayAttendance.value._id,
206
+ payload as TAttendanceCheckOut
207
+ );
208
+ }
209
+
210
+ message.value = response.message;
211
+ messageColor.value = "success";
212
+ messageSnackbar.value = true;
213
+ dialogShowForm.value = false;
214
+
215
+ await getMyAttendancesRefresh();
216
+ } catch (err: any) {
217
+ message.value = err.data?.message;
218
+ messageColor.value = "error";
219
+ messageSnackbar.value = true;
220
+ } finally {
221
+ submitting.value = false;
222
+ }
223
+ };
224
+ </script>
@@ -56,8 +56,13 @@
56
56
  hide-default-footer
57
57
  @click:row="tableRowClickHandler"
58
58
  style="max-height: calc(100vh - (200px))"
59
- ></v-data-table> </v-card
60
- ></v-col>
59
+ >
60
+ <template v-slot:item.createdAt="{ item }">
61
+ {{ formatDateDDMMYYYYLocal(item.createdAt) }}
62
+ </template></v-data-table
63
+ >
64
+ </v-card></v-col
65
+ >
61
66
 
62
67
  <!-- Create Dialog -->
63
68
  <v-dialog v-model="createDialog" width="450" persistent>
@@ -306,6 +311,8 @@ const orgId = route.params.org as string;
306
311
 
307
312
  const items = ref<Array<Record<string, any>>>([]);
308
313
 
314
+ const { formatDateDDMMYYYYLocal } = useUtils();
315
+
309
316
  const {
310
317
  data: getOnlineFormConfigurationReq,
311
318
  refresh: getOnlineFormConfigurations,
@@ -0,0 +1,410 @@
1
+ <template>
2
+ <v-row no-gutters class="mb-6">
3
+ <v-col cols="12">
4
+ <div class="text-subtitle-2 font-weight-bold mb-3">
5
+ {{ title }}
6
+ <span v-if="required" class="text-error">*</span>
7
+ </div>
8
+
9
+ <v-sheet
10
+ border
11
+ rounded="lg"
12
+ class="pa-8 text-center upload-area"
13
+ :class="{ 'upload-disabled': uploading }"
14
+ style="cursor: pointer; transition: all 0.2s"
15
+ @click="!uploading && (photoOptionsDialog = true)"
16
+ >
17
+ <v-icon
18
+ :icon="uploading ? 'mdi-loading mdi-spin' : 'mdi-camera-plus-outline'"
19
+ size="56"
20
+ :color="uploading ? 'grey' : 'primary'"
21
+ class="mb-3"
22
+ />
23
+ <div class="text-body-1 font-weight-medium mb-1">
24
+ {{ uploading ? "Uploading Photo..." : "Add Photo" }}
25
+ </div>
26
+ <div class="text-caption text-medium-emphasis">
27
+ {{ uploading ? "Please wait" : "Take a photo or upload from device" }}
28
+ </div>
29
+ </v-sheet>
30
+ </v-col>
31
+ </v-row>
32
+
33
+ <v-row v-if="photos.length > 0" no-gutters>
34
+ <v-col
35
+ v-for="(photo, index) in photos"
36
+ :key="index"
37
+ cols="auto"
38
+ class="pa-1"
39
+ >
40
+ <div class="position-relative" style="display: inline-block">
41
+ <v-sheet
42
+ rounded="sm"
43
+ style="width: 88px; height: 64px; overflow: hidden; cursor: pointer"
44
+ class="photo-thumb"
45
+ @click="viewPhoto(photo)"
46
+ >
47
+ <v-img :src="photo" height="64" width="88" cover />
48
+ </v-sheet>
49
+ <v-btn
50
+ icon="mdi-close"
51
+ size="x-small"
52
+ variant="tonal"
53
+ color="error"
54
+ class="delete-btn"
55
+ @click.stop="removePhoto(index)"
56
+ style="position: absolute; top: -8px; right: -8px"
57
+ />
58
+ </div>
59
+ </v-col>
60
+ </v-row>
61
+
62
+ <v-dialog v-model="photoOptionsDialog" max-width="420">
63
+ <v-card>
64
+ <v-card-title class="d-flex align-center pa-4">
65
+ <span class="text-h6 font-weight-bold">Add Photo</span>
66
+ <v-spacer />
67
+ <v-btn
68
+ icon="mdi-close"
69
+ variant="text"
70
+ size="small"
71
+ @click="photoOptionsDialog = false"
72
+ />
73
+ </v-card-title>
74
+
75
+ <v-divider />
76
+
77
+ <v-card-text class="pa-4">
78
+ <v-list class="pa-0" bg-color="transparent">
79
+ <v-list-item class="rounded-lg mb-2" @click="openCameraFromOptions">
80
+ <template v-slot:prepend>
81
+ <v-avatar color="primary" variant="tonal" size="48">
82
+ <v-icon icon="mdi-camera" color="primary" />
83
+ </v-avatar>
84
+ </template>
85
+ <v-list-item-title class="font-weight-medium mb-1">
86
+ Take a Photo
87
+ </v-list-item-title>
88
+ <v-list-item-subtitle class="text-caption">
89
+ Use device camera to capture an image
90
+ </v-list-item-subtitle>
91
+ </v-list-item>
92
+
93
+ <v-list-item class="rounded-lg" @click="triggerFileInput">
94
+ <template v-slot:prepend>
95
+ <v-avatar color="primary" variant="tonal" size="48">
96
+ <v-icon icon="mdi-upload" color="primary" />
97
+ </v-avatar>
98
+ </template>
99
+ <v-list-item-title class="font-weight-medium mb-1">
100
+ Upload a Photo
101
+ </v-list-item-title>
102
+ <v-list-item-subtitle class="text-caption">
103
+ Select an image from your device
104
+ </v-list-item-subtitle>
105
+ </v-list-item>
106
+ </v-list>
107
+ </v-card-text>
108
+ </v-card>
109
+ </v-dialog>
110
+
111
+ <v-dialog v-model="showCameraDialog" max-width="720">
112
+ <v-card>
113
+ <v-toolbar density="compact">
114
+ <v-toolbar-title class="text-subtitle-1 font-weight-medium">
115
+ Camera
116
+ </v-toolbar-title>
117
+ <v-spacer />
118
+ <v-btn icon @click="closeCameraDialog">
119
+ <v-icon>mdi-close</v-icon>
120
+ </v-btn>
121
+ </v-toolbar>
122
+ <v-card-text>
123
+ <div class="d-flex flex-column align-center">
124
+ <div style="width: 100%; max-width: 640px">
125
+ <video
126
+ ref="videoRef"
127
+ autoplay
128
+ muted
129
+ playsinline
130
+ style="width: 100%; border-radius: 8px; background: #000"
131
+ />
132
+ </div>
133
+ <canvas ref="canvasRef" style="display: none"></canvas>
134
+ <div class="text-caption grey--text mt-2">
135
+ Tip: Allow camera permissions to use this feature.
136
+ </div>
137
+ </div>
138
+ </v-card-text>
139
+ <v-card-actions>
140
+ <v-btn
141
+ text
142
+ @click="closeCameraDialog"
143
+ class="text-none font-weight-medium"
144
+ >
145
+ CANCEL
146
+ </v-btn>
147
+ <v-spacer />
148
+ <v-btn
149
+ block
150
+ variant="flat"
151
+ color="black"
152
+ class="text-none font-weight-bold rounded-0"
153
+ height="56"
154
+ size="large"
155
+ @click="capturePhoto"
156
+ >
157
+ <v-icon class="mr-2">mdi-camera</v-icon>
158
+ CAPTURE
159
+ </v-btn>
160
+ </v-card-actions>
161
+ </v-card>
162
+ </v-dialog>
163
+
164
+ <input
165
+ ref="fileInputRef"
166
+ type="file"
167
+ accept="image/*"
168
+ style="display: none"
169
+ @change="onFileSelected"
170
+ />
171
+ </template>
172
+
173
+ <script setup lang="ts">
174
+ const props = defineProps({
175
+ title: {
176
+ type: String,
177
+ default: "Photos (Optional)",
178
+ },
179
+ required: {
180
+ type: Boolean,
181
+ default: false,
182
+ },
183
+ modelValue: {
184
+ type: Array as PropType<string[]>,
185
+ default: () => [],
186
+ },
187
+ photoIds: {
188
+ type: Array as PropType<string[]>,
189
+ default: () => [],
190
+ },
191
+ });
192
+
193
+ const emit = defineEmits(["update:modelValue", "update:photoIds", "error"]);
194
+
195
+ const photos = ref<string[]>([...props.modelValue]);
196
+ const photosIds = ref<string[]>([...props.photoIds]);
197
+ const photoOptionsDialog = ref(false);
198
+ const showCameraDialog = ref(false);
199
+ const videoRef = ref<HTMLVideoElement | null>(null);
200
+ const canvasRef = ref<HTMLCanvasElement | null>(null);
201
+ const fileInputRef = ref<HTMLInputElement | null>(null);
202
+ let mediaStream: MediaStream | null = null;
203
+ const uploading = ref(false);
204
+
205
+ const { addFile } = useFile();
206
+
207
+ function viewPhoto(photoData: string) {
208
+ const link = document.createElement("a");
209
+ link.href = photoData;
210
+ link.target = "_blank";
211
+ link.click();
212
+ }
213
+
214
+ watch(
215
+ () => props.modelValue,
216
+ (newVal) => {
217
+ if (JSON.stringify(newVal) !== JSON.stringify(photos.value)) {
218
+ photos.value = [...newVal];
219
+ }
220
+ },
221
+ );
222
+
223
+ watch(
224
+ () => props.photoIds,
225
+ (newVal) => {
226
+ if (JSON.stringify(newVal) !== JSON.stringify(photosIds.value)) {
227
+ photosIds.value = [...newVal];
228
+ }
229
+ },
230
+ );
231
+
232
+ watch(
233
+ photos,
234
+ (newVal) => {
235
+ if (JSON.stringify(newVal) !== JSON.stringify(props.modelValue)) {
236
+ emit("update:modelValue", newVal);
237
+ }
238
+ },
239
+ { deep: true },
240
+ );
241
+
242
+ watch(
243
+ photosIds,
244
+ (newVal) => {
245
+ if (JSON.stringify(newVal) !== JSON.stringify(props.photoIds)) {
246
+ emit("update:photoIds", newVal);
247
+ }
248
+ },
249
+ { deep: true },
250
+ );
251
+
252
+ function triggerFileInput() {
253
+ photoOptionsDialog.value = false;
254
+ fileInputRef.value?.click();
255
+ }
256
+
257
+ function onFileSelected(e: Event) {
258
+ const input = e.target as HTMLInputElement;
259
+ const file = input.files?.[0];
260
+ if (!file) return;
261
+
262
+ const reader = new FileReader();
263
+ reader.onload = async () => {
264
+ try {
265
+ if (typeof reader.result === "string") {
266
+ photos.value = [...photos.value, reader.result];
267
+ }
268
+ uploading.value = true;
269
+ const uploadRes = await addFile(file);
270
+ if (uploadRes?.id) {
271
+ photosIds.value = [...photosIds.value, uploadRes.id];
272
+ }
273
+ } catch (err) {
274
+ console.error("File upload error", err);
275
+ emit("error", "Failed to upload image");
276
+
277
+ if (photos.value.length > photosIds.value.length) {
278
+ photos.value.pop();
279
+ }
280
+ } finally {
281
+ uploading.value = false;
282
+ }
283
+ };
284
+ reader.readAsDataURL(file);
285
+ input.value = "";
286
+ }
287
+
288
+ async function openCameraFromOptions() {
289
+ photoOptionsDialog.value = false;
290
+ showCameraDialog.value = true;
291
+ try {
292
+ mediaStream = await navigator.mediaDevices.getUserMedia({
293
+ video: true,
294
+ audio: false,
295
+ });
296
+ if (videoRef.value) {
297
+ videoRef.value.srcObject = mediaStream;
298
+ await videoRef.value.play();
299
+ }
300
+ } catch (err) {
301
+ console.error("Camera error", err);
302
+ emit("error", "Unable to access camera");
303
+ showCameraDialog.value = false;
304
+ }
305
+ }
306
+
307
+ function capturePhoto() {
308
+ if (!videoRef.value) return;
309
+
310
+ const video = videoRef.value;
311
+ const canvas = canvasRef.value || document.createElement("canvas");
312
+ canvas.width = video.videoWidth || 640;
313
+ canvas.height = video.videoHeight || 480;
314
+ const ctx = canvas.getContext("2d");
315
+ if (!ctx) return;
316
+
317
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
318
+ const data = canvas.toDataURL("image/jpeg", 0.9);
319
+ photos.value = [...photos.value, data];
320
+
321
+ // Convert to blob and upload
322
+ canvas.toBlob(async (blob) => {
323
+ if (!blob) {
324
+ closeCameraDialog();
325
+ return;
326
+ }
327
+
328
+ const file = new File([blob], `photo-${Date.now()}.jpg`, {
329
+ type: "image/jpeg",
330
+ });
331
+
332
+ try {
333
+ uploading.value = true;
334
+ const uploadRes = await addFile(file);
335
+ if (uploadRes?.id) {
336
+ photosIds.value = [...photosIds.value, uploadRes.id];
337
+ }
338
+ } catch (err) {
339
+ console.error("Capture upload error", err);
340
+ emit("error", "Failed to upload captured image");
341
+ // Remove the preview if upload failed
342
+ if (photos.value.length > photosIds.value.length) {
343
+ photos.value.pop();
344
+ }
345
+ } finally {
346
+ uploading.value = false;
347
+ closeCameraDialog();
348
+ }
349
+ }, "image/jpeg");
350
+ }
351
+
352
+ function closeCameraDialog() {
353
+ showCameraDialog.value = false;
354
+ if (mediaStream) {
355
+ mediaStream.getTracks().forEach((t) => t.stop());
356
+ mediaStream = null;
357
+ }
358
+ if (videoRef.value) {
359
+ videoRef.value.pause();
360
+ videoRef.value.srcObject = null;
361
+ }
362
+ }
363
+
364
+ function removePhoto(index: number) {
365
+ if (index < 0 || index >= photos.value.length) return;
366
+ photos.value.splice(index, 1);
367
+ if (photosIds.value && photosIds.value.length > index) {
368
+ photosIds.value.splice(index, 1);
369
+ }
370
+ }
371
+
372
+ onBeforeUnmount(() => {
373
+ if (mediaStream) {
374
+ mediaStream.getTracks().forEach((t) => t.stop());
375
+ mediaStream = null;
376
+ }
377
+ });
378
+ </script>
379
+
380
+ <style scoped>
381
+ .upload-area {
382
+ &:hover:not(.upload-disabled) {
383
+ border-color: rgb(var(--v-theme-primary)) !important;
384
+ background-color: rgba(var(--v-theme-primary), 0.04);
385
+ }
386
+ }
387
+
388
+ .upload-disabled {
389
+ cursor: not-allowed !important;
390
+ opacity: 0.6;
391
+ }
392
+
393
+ .photo-thumb {
394
+ cursor: pointer;
395
+ transition: transform 0.2s;
396
+ }
397
+
398
+ .photo-thumb:hover {
399
+ transform: scale(1.05);
400
+ }
401
+
402
+ .delete-btn {
403
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
404
+ transition: all 0.2s;
405
+ }
406
+
407
+ .delete-btn:hover {
408
+ transform: scale(1.1);
409
+ }
410
+ </style>