@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.
- package/CHANGELOG.md +6 -0
- package/components/AcceptDialog.vue +44 -0
- package/components/AccessCardAddForm.vue +101 -13
- package/components/AccessManagement.vue +130 -47
- package/components/AddSupplyForm.vue +165 -0
- package/components/AreaChecklistHistoryLogs.vue +235 -0
- package/components/AreaChecklistHistoryMain.vue +176 -0
- package/components/AreaFormDialog.vue +266 -0
- package/components/AreaMain.vue +841 -0
- package/components/AttendanceCheckInOutDialog.vue +416 -0
- package/components/AttendanceDetailsDialog.vue +184 -0
- package/components/AttendanceMain.vue +155 -0
- package/components/AttendanceMapSearchDialog.vue +393 -0
- package/components/AttendanceSettingsDialog.vue +398 -0
- package/components/BuildingManagement/buildings.vue +5 -5
- package/components/BuildingManagement/units.vue +5 -5
- package/components/ChecklistItemRow.vue +54 -0
- package/components/CheckoutItemMain.vue +705 -0
- package/components/CleaningScheduleMain.vue +271 -0
- package/components/DocumentManagement.vue +4 -0
- package/components/EntryPass/QrTemplatePreview.vue +104 -0
- package/components/EntryPassMain.vue +252 -200
- package/components/HygieneUpdateMoreAction.vue +238 -0
- package/components/ManageChecklistMain.vue +384 -0
- package/components/MemberMain.vue +48 -20
- package/components/MyAttendanceMain.vue +224 -0
- package/components/OnlineFormsConfiguration.vue +9 -2
- package/components/PhotoUpload.vue +410 -0
- package/components/ScheduleAreaMain.vue +313 -0
- package/components/ScheduleTaskAreaFormDialog.vue +144 -0
- package/components/ScheduleTaskAreaUpdateMoreAction.vue +109 -0
- package/components/ScheduleTaskForm.vue +471 -0
- package/components/ScheduleTaskMain.vue +345 -0
- package/components/ScheduleTastTicketMain.vue +182 -0
- package/components/StockCard.vue +191 -0
- package/components/SupplyManagementMain.vue +557 -0
- package/components/TableHygiene.vue +617 -0
- package/components/UnitMain.vue +451 -0
- package/components/VisitorManagement.vue +28 -15
- package/composables/useAccessManagement.ts +90 -0
- package/composables/useAreaPermission.ts +51 -0
- package/composables/useAreas.ts +99 -0
- package/composables/useAttendance.ts +89 -0
- package/composables/useAttendancePermission.ts +68 -0
- package/composables/useBuilding.ts +2 -2
- package/composables/useBuildingUnit.ts +2 -2
- package/composables/useCard.ts +2 -0
- package/composables/useCheckout.ts +61 -0
- package/composables/useCheckoutPermission.ts +80 -0
- package/composables/useCleaningPermission.ts +229 -0
- package/composables/useCleaningSchedulePermission.ts +58 -0
- package/composables/useCleaningSchedules.ts +233 -0
- package/composables/useCountry.ts +8 -0
- package/composables/useDashboardData.ts +2 -2
- package/composables/useFeedback.ts +1 -1
- package/composables/useLocation.ts +78 -0
- package/composables/useOnlineForm.ts +16 -9
- package/composables/usePeople.ts +87 -72
- package/composables/useQR.ts +29 -0
- package/composables/useScheduleTask.ts +89 -0
- package/composables/useScheduleTaskArea.ts +85 -0
- package/composables/useScheduleTaskPermission.ts +68 -0
- package/composables/useSiteEntryPassSettings.ts +4 -15
- package/composables/useStock.ts +45 -0
- package/composables/useSupply.ts +63 -0
- package/composables/useSupplyPermission.ts +92 -0
- package/composables/useUnitPermission.ts +51 -0
- package/composables/useUnits.ts +82 -0
- package/composables/useWebUsb.ts +389 -0
- package/composables/useWorkOrder.ts +1 -1
- package/nuxt.config.ts +3 -0
- package/package.json +4 -1
- package/types/area.d.ts +22 -0
- package/types/attendance.d.ts +38 -0
- package/types/checkout-item.d.ts +27 -0
- package/types/cleaner-schedule.d.ts +54 -0
- package/types/location.d.ts +42 -0
- package/types/schedule-task.d.ts +18 -0
- package/types/stock.d.ts +16 -0
- package/types/supply.d.ts +11 -0
- 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
|
-
|
|
60
|
-
|
|
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>
|