@7365admin1/layer-common 1.10.10 → 1.11.0
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/AccessCardAddForm.vue +1 -1
- package/components/AccessCardAssignToUnitForm.vue +10 -13
- package/components/AccessCardQrTagging.vue +2 -2
- package/components/Chat/SkeletonLoader.vue +71 -0
- package/components/DashboardMain.vue +176 -0
- package/components/EntryPassInformation.vue +3 -7
- package/components/FileInputWithList.vue +304 -0
- package/components/IncidentReport/IncidentInformation.vue +14 -2
- package/components/IncidentReport/IncidentInformationDownload.vue +22 -9
- package/components/IncidentReport/affectedEntities.vue +5 -0
- package/components/Signature.vue +133 -0
- package/components/SlideCardGroup.vue +194 -0
- package/components/VisitorForm.vue +17 -1
- package/composables/useAccessManagement.ts +25 -6
- package/composables/useComment.ts +147 -0
- package/composables/useFeedback.ts +79 -29
- package/composables/usePDFDownload.ts +1 -1
- package/composables/useWorkOrder.ts +61 -26
- package/package.json +2 -1
- package/public/default-image.svg +4 -0
- package/public/placeholder-image.svg +6 -0
- package/types/comment.d.ts +38 -0
- package/types/dashboard.d.ts +12 -0
- package/types/feedback.d.ts +56 -20
- package/types/work-order.d.ts +54 -18
- package/utils/data.ts +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -655,7 +655,7 @@ watch(() => card.value.showAssign, async (newVal) => {
|
|
|
655
655
|
});
|
|
656
656
|
buildingsData.value = response.items || [];
|
|
657
657
|
buildingItems.value = buildingsData.value.map((building: any) => ({
|
|
658
|
-
name: building.
|
|
658
|
+
name: building.block,
|
|
659
659
|
value: building._id,
|
|
660
660
|
}));
|
|
661
661
|
} catch (error) {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
hide-details="auto"
|
|
23
23
|
item-title="name"
|
|
24
24
|
item-value="value"
|
|
25
|
-
placeholder="Select
|
|
25
|
+
placeholder="Select block..."
|
|
26
26
|
persistent-placeholder
|
|
27
27
|
:rules="[requiredRule]"
|
|
28
28
|
:loading="buildingLoading"
|
|
@@ -98,7 +98,6 @@
|
|
|
98
98
|
<InputLabel
|
|
99
99
|
class="text-capitalize font-weight-bold"
|
|
100
100
|
title="Access Level"
|
|
101
|
-
required
|
|
102
101
|
/>
|
|
103
102
|
<v-select
|
|
104
103
|
v-model="form.accessLevel"
|
|
@@ -107,7 +106,6 @@
|
|
|
107
106
|
hide-details="auto"
|
|
108
107
|
item-title="name"
|
|
109
108
|
item-value="no"
|
|
110
|
-
:rules="[requiredRule]"
|
|
111
109
|
:loading="levelsLoading"
|
|
112
110
|
placeholder="Select access level..."
|
|
113
111
|
/>
|
|
@@ -118,7 +116,6 @@
|
|
|
118
116
|
<InputLabel
|
|
119
117
|
class="text-capitalize font-weight-bold"
|
|
120
118
|
title="Lift Access Level"
|
|
121
|
-
required
|
|
122
119
|
/>
|
|
123
120
|
<v-select
|
|
124
121
|
v-model="form.liftAccessLevel"
|
|
@@ -127,7 +124,6 @@
|
|
|
127
124
|
hide-details="auto"
|
|
128
125
|
item-title="name"
|
|
129
126
|
item-value="no"
|
|
130
|
-
:rules="[requiredRule]"
|
|
131
127
|
:loading="levelsLoading"
|
|
132
128
|
placeholder="Select lift access level..."
|
|
133
129
|
/>
|
|
@@ -245,8 +241,9 @@ const typeItems = [
|
|
|
245
241
|
{ label: "Non Physical Access Card", value: "QRCODE" },
|
|
246
242
|
];
|
|
247
243
|
|
|
248
|
-
const
|
|
249
|
-
const
|
|
244
|
+
const NO_SELECTION = { name: "No Selection", no: null };
|
|
245
|
+
const accessLevelItems = ref<{ name: string; no: string | null }[]>([]);
|
|
246
|
+
const liftAccessLevelItems = ref<{ name: string; no: string | null }[]>([]);
|
|
250
247
|
const buildingItems = ref<{ name: string; value: string }[]>([]);
|
|
251
248
|
const buildingsData = ref<Record<string, any>[]>([]);
|
|
252
249
|
const levelItems = ref<{ name: string; value: string }[]>([]);
|
|
@@ -268,7 +265,7 @@ function maxQuantityRule(v: number) {
|
|
|
268
265
|
}
|
|
269
266
|
|
|
270
267
|
async function fetchAvailableCount() {
|
|
271
|
-
if (!form.value.accessLevel
|
|
268
|
+
if (!form.value.accessLevel && !form.value.liftAccessLevel) {
|
|
272
269
|
availableCount.value = null;
|
|
273
270
|
return;
|
|
274
271
|
}
|
|
@@ -315,7 +312,7 @@ onMounted(async () => {
|
|
|
315
312
|
if (buildingsResult.status === "fulfilled") {
|
|
316
313
|
buildingsData.value = buildingsResult.value.items || [];
|
|
317
314
|
buildingItems.value = buildingsData.value.map((b: any) => ({
|
|
318
|
-
name: b.
|
|
315
|
+
name: b.block,
|
|
319
316
|
value: b._id,
|
|
320
317
|
}));
|
|
321
318
|
}
|
|
@@ -329,8 +326,8 @@ onMounted(async () => {
|
|
|
329
326
|
_getDoorAccessLevels(acmUrl),
|
|
330
327
|
_getLiftAccessLevels(acmUrl),
|
|
331
328
|
]);
|
|
332
|
-
accessLevelItems.value = doorLevels.data ?? [];
|
|
333
|
-
liftAccessLevelItems.value = liftLevels.data ?? [];
|
|
329
|
+
accessLevelItems.value = [NO_SELECTION, ...(doorLevels.data ?? [])];
|
|
330
|
+
liftAccessLevelItems.value = [NO_SELECTION, ...(liftLevels.data ?? [])];
|
|
334
331
|
} catch {
|
|
335
332
|
emit(
|
|
336
333
|
"error",
|
|
@@ -423,8 +420,8 @@ async function submit() {
|
|
|
423
420
|
type: form.value.type,
|
|
424
421
|
site: siteId.value,
|
|
425
422
|
userType: form.value.userType,
|
|
426
|
-
accessLevel: form.value.accessLevel
|
|
427
|
-
liftAccessLevel: form.value.liftAccessLevel
|
|
423
|
+
accessLevel: form.value.accessLevel,
|
|
424
|
+
liftAccessLevel: form.value.liftAccessLevel,
|
|
428
425
|
});
|
|
429
426
|
emit("success");
|
|
430
427
|
} catch (error: any) {
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
<qrcode-vue
|
|
159
159
|
v-for="qrCode in qrCodesToGenerate"
|
|
160
160
|
:key="qrCode._id"
|
|
161
|
-
:value="qrCode.
|
|
161
|
+
:value="qrCode.cardNo"
|
|
162
162
|
:size="150"
|
|
163
163
|
:data-card-id="qrCode._id"
|
|
164
164
|
/>
|
|
@@ -340,7 +340,7 @@ async function generateQrCodes() {
|
|
|
340
340
|
qrCodesToGenerate.value = filteredCards.map((card) => ({
|
|
341
341
|
_id: card._id,
|
|
342
342
|
cardNo: card.cardNo,
|
|
343
|
-
qrData: card.
|
|
343
|
+
qrData: card.cardNo,
|
|
344
344
|
qrTagCardNo: card.qrTagCardNo ?? "",
|
|
345
345
|
}));
|
|
346
346
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<!-- Chat Messages Skeleton Loader -->
|
|
4
|
+
<v-row no-gutters class="chat-content">
|
|
5
|
+
<v-col
|
|
6
|
+
v-for="(message, index) in chatSkeletons"
|
|
7
|
+
:key="index"
|
|
8
|
+
cols="12"
|
|
9
|
+
class="d-flex my-2"
|
|
10
|
+
:class="message.isRight ? 'justify-end' : 'justify-start'"
|
|
11
|
+
>
|
|
12
|
+
<v-skeleton-loader
|
|
13
|
+
class="rounded-lg border"
|
|
14
|
+
type="article"
|
|
15
|
+
width="400"
|
|
16
|
+
boilerplate
|
|
17
|
+
></v-skeleton-loader>
|
|
18
|
+
</v-col>
|
|
19
|
+
</v-row>
|
|
20
|
+
|
|
21
|
+
<v-footer class="pa-0 chat-footer" color="background">
|
|
22
|
+
<v-row align="center" justify="space-between" no-gutters class="">
|
|
23
|
+
<v-col cols="9" class="pr-3">
|
|
24
|
+
<v-skeleton-loader
|
|
25
|
+
type="paragraph"
|
|
26
|
+
height="100"
|
|
27
|
+
class="rounded-lg border"
|
|
28
|
+
></v-skeleton-loader>
|
|
29
|
+
</v-col>
|
|
30
|
+
|
|
31
|
+
<v-col cols="3" class="d-flex justify-end">
|
|
32
|
+
<v-skeleton-loader
|
|
33
|
+
color="background"
|
|
34
|
+
type="button"
|
|
35
|
+
width="150"
|
|
36
|
+
height="150"
|
|
37
|
+
class="rounded-lg"
|
|
38
|
+
></v-skeleton-loader>
|
|
39
|
+
</v-col>
|
|
40
|
+
</v-row>
|
|
41
|
+
</v-footer>
|
|
42
|
+
</div>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<script setup>
|
|
46
|
+
import { ref } from "vue";
|
|
47
|
+
|
|
48
|
+
const chatSkeletons = ref([
|
|
49
|
+
{ isRight: false },
|
|
50
|
+
{ isRight: true },
|
|
51
|
+
{ isRight: false },
|
|
52
|
+
]);
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<style scoped>
|
|
56
|
+
.chat-content {
|
|
57
|
+
max-height: calc(100vh - (55px + 98px + 100px));
|
|
58
|
+
overflow-y: auto;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.chat-footer {
|
|
62
|
+
position: sticky;
|
|
63
|
+
bottom: 0;
|
|
64
|
+
width: 100%;
|
|
65
|
+
background-color: white;
|
|
66
|
+
border-top: 1px solid #ddd;
|
|
67
|
+
display: flex;
|
|
68
|
+
justify-content: center;
|
|
69
|
+
align-items: center;
|
|
70
|
+
}
|
|
71
|
+
</style>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-row no-gutters class="mb-6">
|
|
3
|
+
<v-col cols="12">
|
|
4
|
+
<h1 class="text-h4 text-md-h3 font-weight-bold">
|
|
5
|
+
Dashboard{{ currentSiteName ? ` - ${currentSiteName}` : "" }}
|
|
6
|
+
</h1>
|
|
7
|
+
</v-col>
|
|
8
|
+
</v-row>
|
|
9
|
+
|
|
10
|
+
<v-row>
|
|
11
|
+
<v-col cols="12">
|
|
12
|
+
<v-progress-linear
|
|
13
|
+
v-if="loading"
|
|
14
|
+
indeterminate
|
|
15
|
+
color="primary"
|
|
16
|
+
class="mb-4"
|
|
17
|
+
/>
|
|
18
|
+
</v-col>
|
|
19
|
+
</v-row>
|
|
20
|
+
|
|
21
|
+
<v-row>
|
|
22
|
+
<v-col
|
|
23
|
+
v-for="card in countCardList"
|
|
24
|
+
:key="card.id"
|
|
25
|
+
cols="12"
|
|
26
|
+
sm="6"
|
|
27
|
+
md="6"
|
|
28
|
+
lg="4"
|
|
29
|
+
xl="2"
|
|
30
|
+
>
|
|
31
|
+
<v-card flat border class="fill-height" elevation="0" hover>
|
|
32
|
+
<v-card-text class="pa-4 pa-md-6">
|
|
33
|
+
<v-row no-gutters align="start">
|
|
34
|
+
<v-col cols="8">
|
|
35
|
+
<v-card-subtitle class="text-caption text-uppercase pa-0 mb-2">
|
|
36
|
+
{{ card.label }}
|
|
37
|
+
</v-card-subtitle>
|
|
38
|
+
<v-card-title class="text-h4 text-md-h3 pa-0 mb-3">
|
|
39
|
+
{{ card.value }}
|
|
40
|
+
</v-card-title>
|
|
41
|
+
<v-chip :color="card.chipColor" size="small" variant="flat">
|
|
42
|
+
<v-icon size="14" start>mdi-trending-up</v-icon>
|
|
43
|
+
{{ card.percentage }}
|
|
44
|
+
</v-chip>
|
|
45
|
+
</v-col>
|
|
46
|
+
<v-col cols="4" class="d-flex justify-end">
|
|
47
|
+
<v-avatar :color="card.color" size="56" rounded="lg">
|
|
48
|
+
<v-icon :icon="card.icon" color="white" size="28"></v-icon>
|
|
49
|
+
</v-avatar>
|
|
50
|
+
</v-col>
|
|
51
|
+
</v-row>
|
|
52
|
+
<v-row no-gutters class="mt-4">
|
|
53
|
+
<v-col cols="12">
|
|
54
|
+
<v-select
|
|
55
|
+
:model-value="card.period"
|
|
56
|
+
:items="periodOptions"
|
|
57
|
+
density="compact"
|
|
58
|
+
hide-details
|
|
59
|
+
variant="outlined"
|
|
60
|
+
rounded="lg"
|
|
61
|
+
:disabled="loading"
|
|
62
|
+
@update:model-value="onUpdateCardPeriod(card.key, $event)"
|
|
63
|
+
></v-select>
|
|
64
|
+
</v-col>
|
|
65
|
+
</v-row>
|
|
66
|
+
</v-card-text>
|
|
67
|
+
</v-card>
|
|
68
|
+
</v-col>
|
|
69
|
+
</v-row>
|
|
70
|
+
|
|
71
|
+
<Snackbar v-model="messageSnackbar" :text="message" :color="messageColor" />
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<script setup lang="ts">
|
|
75
|
+
|
|
76
|
+
const props = defineProps({
|
|
77
|
+
orgId: {
|
|
78
|
+
type: String,
|
|
79
|
+
default: "",
|
|
80
|
+
},
|
|
81
|
+
site: {
|
|
82
|
+
type: String,
|
|
83
|
+
default: "",
|
|
84
|
+
},
|
|
85
|
+
dashboardData: {
|
|
86
|
+
type: Object as () => Record<string, any>,
|
|
87
|
+
default: () => ({}),
|
|
88
|
+
},
|
|
89
|
+
loading: {
|
|
90
|
+
type: Boolean,
|
|
91
|
+
default: false,
|
|
92
|
+
},
|
|
93
|
+
cardPeriods: {
|
|
94
|
+
type: Object as () => TPeriodState,
|
|
95
|
+
required: true,
|
|
96
|
+
},
|
|
97
|
+
currentSiteName: {
|
|
98
|
+
type: String,
|
|
99
|
+
default: "",
|
|
100
|
+
}, cardValues: {
|
|
101
|
+
type: Array as () => TDashboardCardValue[],
|
|
102
|
+
required: true,
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const emit = defineEmits<{
|
|
107
|
+
updatePeriod: [payload: { key: string; period: string }];
|
|
108
|
+
}>();
|
|
109
|
+
|
|
110
|
+
const periodOptions = ["Today", "This Week", "This Month"];
|
|
111
|
+
|
|
112
|
+
const periodValue: Record<string, TDashboardValues> = {
|
|
113
|
+
Today: "today",
|
|
114
|
+
"This Week": "thisWeek",
|
|
115
|
+
"This Month": "thisMonth",
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const datePeriod: Record<TDashboardValues, string> = {
|
|
119
|
+
today: "Today",
|
|
120
|
+
thisWeek: "This Week",
|
|
121
|
+
thisMonth: "This Month",
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const message = ref("");
|
|
125
|
+
const messageSnackbar = ref(false);
|
|
126
|
+
const messageColor = ref("");
|
|
127
|
+
|
|
128
|
+
const countCardList = computed(() => {
|
|
129
|
+
const fetchedData = props.dashboardData || {};
|
|
130
|
+
const hasFetchedData = fetchedData && Object.keys(fetchedData).length > 0;
|
|
131
|
+
|
|
132
|
+
const sourceCards = hasFetchedData
|
|
133
|
+
? buildCardsFromFetchedData(fetchedData)
|
|
134
|
+
: [];
|
|
135
|
+
|
|
136
|
+
return sourceCards.map((card: any, idx: number) => {
|
|
137
|
+
const periodKey = card.periodKey as keyof TPeriodState;
|
|
138
|
+
const apiPeriod = props.cardPeriods[periodKey] || "today";
|
|
139
|
+
const displayPeriod = datePeriod[apiPeriod] || "Today";
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
id: idx + 1,
|
|
143
|
+
key: card.key,
|
|
144
|
+
label: card.title || `Card ${idx + 1}`,
|
|
145
|
+
value:
|
|
146
|
+
card.value >= 1000
|
|
147
|
+
? card.value.toLocaleString()
|
|
148
|
+
: card.value.toString(),
|
|
149
|
+
icon: card.icon,
|
|
150
|
+
color: card.color,
|
|
151
|
+
percentage: `${Number(card.percentage) > 0 ? "+" : ""}${
|
|
152
|
+
card.percentage
|
|
153
|
+
}%`,
|
|
154
|
+
chipColor: card.isPositive ? "success" : "error",
|
|
155
|
+
period: displayPeriod,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
function buildCardsFromFetchedData(data: Record<string, any>) {
|
|
161
|
+
return props.cardValues.map((mapping) => ({
|
|
162
|
+
key: mapping.key,
|
|
163
|
+
periodKey: mapping.periodKey,
|
|
164
|
+
title: mapping.title,
|
|
165
|
+
value: data[mapping.key]?.count ?? 0,
|
|
166
|
+
percentage: data[mapping.key]?.percentage ?? 0,
|
|
167
|
+
icon: mapping.icon,
|
|
168
|
+
color: mapping.color,
|
|
169
|
+
isPositive: (data[mapping.key]?.percentage ?? 0) >= 0,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function onUpdateCardPeriod(cardKey: string, period: string) {
|
|
174
|
+
emit("updatePeriod", { key: cardKey, period });
|
|
175
|
+
}
|
|
176
|
+
</script>
|
|
@@ -82,13 +82,6 @@
|
|
|
82
82
|
placeholder="Select cards..."
|
|
83
83
|
return-object
|
|
84
84
|
>
|
|
85
|
-
<template #item="{ props: itemProps, item }">
|
|
86
|
-
<v-list-item v-bind="itemProps">
|
|
87
|
-
<template #subtitle>
|
|
88
|
-
<span class="text-caption text-grey">{{ item.raw.cardNo }}</span>
|
|
89
|
-
</template>
|
|
90
|
-
</v-list-item>
|
|
91
|
-
</template>
|
|
92
85
|
</v-select>
|
|
93
86
|
</div>
|
|
94
87
|
|
|
@@ -421,6 +414,9 @@ const scanFrame = async () => {
|
|
|
421
414
|
addCard(card);
|
|
422
415
|
closeCameraDialog();
|
|
423
416
|
return;
|
|
417
|
+
} else if (barcode.rawValue) {
|
|
418
|
+
showSnackbar(`Card "${barcode.rawValue}" not found in available cards.`, "warning");
|
|
419
|
+
return;
|
|
424
420
|
}
|
|
425
421
|
}
|
|
426
422
|
} catch {
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<v-file-input
|
|
4
|
+
v-model="files"
|
|
5
|
+
:label="label"
|
|
6
|
+
accept="image/*"
|
|
7
|
+
:prepend-icon="prependIcon"
|
|
8
|
+
hide-details
|
|
9
|
+
show-size
|
|
10
|
+
chips
|
|
11
|
+
multiple
|
|
12
|
+
clearable
|
|
13
|
+
@update:modelValue="handleFileSelect"
|
|
14
|
+
@click:clear="handleClear"
|
|
15
|
+
:hide-input="hasHideInput"
|
|
16
|
+
>
|
|
17
|
+
<template v-slot:append v-if="hasLabel">
|
|
18
|
+
<slot name="append">
|
|
19
|
+
<v-btn color="primary" height="50px" @click="openCameraDialog">
|
|
20
|
+
<v-icon>mdi-camera</v-icon>
|
|
21
|
+
</v-btn>
|
|
22
|
+
</slot>
|
|
23
|
+
</template>
|
|
24
|
+
</v-file-input>
|
|
25
|
+
|
|
26
|
+
<v-row no-gutters v-if="hasLabel">
|
|
27
|
+
<v-col cols="12" class="mt-1">
|
|
28
|
+
<v-chip-group column>
|
|
29
|
+
<template v-for="(file, index) in files" :key="file.name">
|
|
30
|
+
<v-chip
|
|
31
|
+
closable
|
|
32
|
+
class="text-wrap text-caption custom-chip"
|
|
33
|
+
@click:close="removeFile(index)"
|
|
34
|
+
>
|
|
35
|
+
<span class="chip-text">{{ file.name }}</span>
|
|
36
|
+
</v-chip>
|
|
37
|
+
</template>
|
|
38
|
+
</v-chip-group>
|
|
39
|
+
</v-col>
|
|
40
|
+
</v-row>
|
|
41
|
+
|
|
42
|
+
<!-- Camera Dialog -->
|
|
43
|
+
<v-dialog
|
|
44
|
+
v-model="showCameraDialog"
|
|
45
|
+
transition="dialog-bottom-transition"
|
|
46
|
+
width="800"
|
|
47
|
+
max-width="800"
|
|
48
|
+
persistent
|
|
49
|
+
@after-enter="startCamera"
|
|
50
|
+
>
|
|
51
|
+
<v-container
|
|
52
|
+
class="d-flex justify-center"
|
|
53
|
+
max-height="90vh"
|
|
54
|
+
width="800"
|
|
55
|
+
max-width="800"
|
|
56
|
+
>
|
|
57
|
+
<v-card elevation="2" class="d-flex flex-column align-center pa-2">
|
|
58
|
+
<v-toolbar>
|
|
59
|
+
<v-card-title class="text-h5">Take a Picture</v-card-title>
|
|
60
|
+
<v-spacer></v-spacer>
|
|
61
|
+
<v-btn
|
|
62
|
+
color="grey-darken-1"
|
|
63
|
+
icon="mdi-close"
|
|
64
|
+
@click="closeCameraDialog"
|
|
65
|
+
></v-btn>
|
|
66
|
+
</v-toolbar>
|
|
67
|
+
|
|
68
|
+
<div
|
|
69
|
+
id="reader"
|
|
70
|
+
class="d-flex justify-center align-center"
|
|
71
|
+
style="
|
|
72
|
+
position: relative;
|
|
73
|
+
width: 500px;
|
|
74
|
+
min-width: 400px;
|
|
75
|
+
height: 400px;
|
|
76
|
+
"
|
|
77
|
+
>
|
|
78
|
+
<video
|
|
79
|
+
ref="video"
|
|
80
|
+
style="flex: 1; height: 400px; min-width: 300px"
|
|
81
|
+
class="video-shutter"
|
|
82
|
+
autoplay
|
|
83
|
+
></video>
|
|
84
|
+
<canvas
|
|
85
|
+
ref="canvas"
|
|
86
|
+
style="flex: 1; height: 400px; min-width: 300px; display: none"
|
|
87
|
+
></canvas>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<v-row align="center" justify="center">
|
|
91
|
+
<v-col cols="6">
|
|
92
|
+
<v-btn color="primary" icon class="mt-4" @click="switchCamera">
|
|
93
|
+
<v-icon>mdi-camera-switch</v-icon>
|
|
94
|
+
</v-btn>
|
|
95
|
+
</v-col>
|
|
96
|
+
<v-col cols="6">
|
|
97
|
+
<v-btn
|
|
98
|
+
color="secondary"
|
|
99
|
+
icon
|
|
100
|
+
class="mt-4"
|
|
101
|
+
@click="captureImageFromCamera"
|
|
102
|
+
>
|
|
103
|
+
<v-icon large>mdi-camera-outline</v-icon>
|
|
104
|
+
</v-btn>
|
|
105
|
+
</v-col>
|
|
106
|
+
</v-row>
|
|
107
|
+
</v-card>
|
|
108
|
+
</v-container>
|
|
109
|
+
</v-dialog>
|
|
110
|
+
</div>
|
|
111
|
+
</template>
|
|
112
|
+
|
|
113
|
+
<script setup lang="ts">
|
|
114
|
+
interface FileWithPreview {
|
|
115
|
+
name: string;
|
|
116
|
+
data: File;
|
|
117
|
+
progress: number;
|
|
118
|
+
url: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const props = defineProps({
|
|
122
|
+
label: {
|
|
123
|
+
type: String,
|
|
124
|
+
default: "Select File",
|
|
125
|
+
},
|
|
126
|
+
prependIcon: {
|
|
127
|
+
type: String,
|
|
128
|
+
default: "mdi-paperclip",
|
|
129
|
+
},
|
|
130
|
+
required: {
|
|
131
|
+
type: Boolean,
|
|
132
|
+
default: true,
|
|
133
|
+
},
|
|
134
|
+
initFiles: {
|
|
135
|
+
type: Array,
|
|
136
|
+
},
|
|
137
|
+
hasLabel: {
|
|
138
|
+
type: Boolean,
|
|
139
|
+
default: true,
|
|
140
|
+
},
|
|
141
|
+
hasHideInput: {
|
|
142
|
+
type: Boolean,
|
|
143
|
+
default: false,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const emit = defineEmits<{
|
|
148
|
+
(event: "onFileAttach", payload: Array<{ data: File }>): void;
|
|
149
|
+
(event: "update:files", files: FileWithPreview[]): void;
|
|
150
|
+
(event: "onFileRemoved", payload: { index: number; file: File }): void;
|
|
151
|
+
(event: "onClear"): void;
|
|
152
|
+
}>();
|
|
153
|
+
|
|
154
|
+
const { showUploadedFiles } = useUploadFiles();
|
|
155
|
+
|
|
156
|
+
const files = ref<File[]>([]);
|
|
157
|
+
const attachedFiles = ref<FileWithPreview[]>([]);
|
|
158
|
+
const showCameraDialog = ref(false);
|
|
159
|
+
const video = ref<HTMLVideoElement | null>(null);
|
|
160
|
+
const canvas = ref<HTMLCanvasElement | null>(null);
|
|
161
|
+
const cameraFacingMode = ref<"environment" | "user">("environment");
|
|
162
|
+
|
|
163
|
+
const message = ref("");
|
|
164
|
+
const messageColor = ref("");
|
|
165
|
+
const messageSnackbar = ref(false);
|
|
166
|
+
|
|
167
|
+
function showMessage(msg: string, color: string) {
|
|
168
|
+
message.value = msg;
|
|
169
|
+
messageColor.value = color;
|
|
170
|
+
messageSnackbar.value = true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
watchEffect(() => {
|
|
174
|
+
if (Array.isArray(props.initFiles) && props.initFiles.length > 0) {
|
|
175
|
+
files.value = props.initFiles.filter((file) => file && file.name); // Ensure valid files
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
const handleFileSelect = async () => {
|
|
179
|
+
if (files.value && files.value.length > 0) {
|
|
180
|
+
const newFiles = files.value.map((file: File) => ({
|
|
181
|
+
name: file.name,
|
|
182
|
+
data: file,
|
|
183
|
+
progress: 0,
|
|
184
|
+
url: URL.createObjectURL(file),
|
|
185
|
+
}));
|
|
186
|
+
|
|
187
|
+
// attachedFiles.value = [...newFiles];
|
|
188
|
+
showUploadedFiles(newFiles);
|
|
189
|
+
|
|
190
|
+
emit("update:files", newFiles);
|
|
191
|
+
} else {
|
|
192
|
+
files.value = [...(props.initFiles as typeof files.value)];
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const handleClear = () => {
|
|
197
|
+
files.value = [];
|
|
198
|
+
emit("onClear");
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const openCameraDialog = () => {
|
|
202
|
+
showCameraDialog.value = true;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const closeCameraDialog = () => {
|
|
206
|
+
showCameraDialog.value = false;
|
|
207
|
+
stopCamera();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const startCamera = async () => {
|
|
211
|
+
try {
|
|
212
|
+
const constraints = {
|
|
213
|
+
video: {
|
|
214
|
+
facingMode: cameraFacingMode.value,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
219
|
+
if (video.value) {
|
|
220
|
+
video.value.srcObject = stream;
|
|
221
|
+
video.value.play();
|
|
222
|
+
}
|
|
223
|
+
} catch (error: any) {
|
|
224
|
+
showMessage(`Error accessing camera: ${error.message}`, "error");
|
|
225
|
+
closeCameraDialog();
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const stopCamera = () => {
|
|
230
|
+
if (video.value) {
|
|
231
|
+
const stream = video.value.srcObject as MediaStream;
|
|
232
|
+
if (stream) {
|
|
233
|
+
stream.getTracks().forEach((track) => track.stop());
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const switchCamera = async () => {
|
|
239
|
+
await stopCamera();
|
|
240
|
+
cameraFacingMode.value =
|
|
241
|
+
cameraFacingMode.value === "environment" ? "user" : "environment";
|
|
242
|
+
showMessage(
|
|
243
|
+
`Switched to ${
|
|
244
|
+
cameraFacingMode.value === "environment" ? "Back Camera" : "Front Camera"
|
|
245
|
+
}`,
|
|
246
|
+
"error"
|
|
247
|
+
);
|
|
248
|
+
startCamera();
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const captureImageFromCamera = () => {
|
|
252
|
+
if (!video.value || !canvas.value) return;
|
|
253
|
+
|
|
254
|
+
const context = canvas.value.getContext("2d");
|
|
255
|
+
if (!context) return;
|
|
256
|
+
|
|
257
|
+
// Set canvas dimensions to match video
|
|
258
|
+
canvas.value.width = video.value.videoWidth;
|
|
259
|
+
canvas.value.height = video.value.videoHeight;
|
|
260
|
+
|
|
261
|
+
// Capture the frame
|
|
262
|
+
context.drawImage(video.value, 0, 0, canvas.value.width, canvas.value.height);
|
|
263
|
+
|
|
264
|
+
// Convert to file
|
|
265
|
+
canvas.value.toBlob((blob) => {
|
|
266
|
+
if (!blob) return;
|
|
267
|
+
|
|
268
|
+
const file = new File([blob], `camera-capture-${Date.now()}.png`, {
|
|
269
|
+
type: "image/png",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
files.value = [file];
|
|
273
|
+
handleFileSelect();
|
|
274
|
+
closeCameraDialog();
|
|
275
|
+
}, "image/png");
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const removeFile = (index) => {
|
|
279
|
+
const removedFile = files.value[index];
|
|
280
|
+
files.value = files.value.filter((_, i) => i !== index);
|
|
281
|
+
emit("onFileRemoved", { index, file: removedFile }); // Emit when a file is removed
|
|
282
|
+
// emit("onFilesUpdated", files.value); // Emit updated files list
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// Cleanup
|
|
286
|
+
onUnmounted(() => {
|
|
287
|
+
stopCamera();
|
|
288
|
+
});
|
|
289
|
+
</script>
|
|
290
|
+
|
|
291
|
+
<style scoped>
|
|
292
|
+
.custom-chip {
|
|
293
|
+
max-width: 100%;
|
|
294
|
+
height: auto !important;
|
|
295
|
+
white-space: normal;
|
|
296
|
+
padding: 3px 20px;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.chip-text {
|
|
300
|
+
word-break: break-word;
|
|
301
|
+
white-space: normal;
|
|
302
|
+
line-height: 1.2;
|
|
303
|
+
}
|
|
304
|
+
</style>
|