@iservice365/layer-common 1.1.0 → 1.3.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 +21 -0
- package/components/CameraForm.vue +264 -0
- package/components/CameraMain.vue +352 -0
- package/components/Card/DeleteConfirmation.vue +51 -0
- package/components/Card/MemberInfoSummary.vue +44 -0
- package/components/Chat/Information.vue +28 -11
- package/components/Dialog/DeleteConfirmation.vue +51 -0
- package/components/Dialog/UpdateMoreAction.vue +99 -0
- package/components/Feedback/Form.vue +17 -3
- package/components/FeedbackDetail.vue +0 -11
- package/components/FeedbackMain.vue +21 -11
- package/components/Input/DateTimePicker.vue +10 -5
- package/components/Input/File.vue +1 -1
- package/components/Input/FileV2.vue +106 -63
- package/components/Input/InputPhoneNumberV2.vue +114 -0
- package/components/Input/NRICNumber.vue +41 -0
- package/components/Input/PhoneNumber.vue +1 -0
- package/components/Input/VehicleNumber.vue +49 -0
- package/components/NumberSettingField.vue +107 -0
- package/components/PeopleForm.vue +452 -0
- package/components/TableMain.vue +2 -1
- package/components/VehicleUpdateMoreAction.vue +84 -0
- package/components/VisitorForm.vue +712 -0
- package/components/VisitorFormSelection.vue +53 -0
- package/components/VisitorManagement.vue +569 -0
- package/components/WorkOrder/Create.vue +87 -49
- package/components/WorkOrder/Main.vue +17 -12
- package/composables/useBuilding.ts +250 -0
- package/composables/useBuildingUnit.ts +116 -0
- package/composables/useFeedback.ts +3 -3
- package/composables/useFile.ts +7 -9
- package/composables/useLocal.ts +67 -0
- package/composables/usePeople.ts +48 -0
- package/composables/useSecurityUtils.ts +18 -0
- package/composables/useSiteSettings.ts +111 -0
- package/composables/useUtils.ts +30 -1
- package/composables/useVisitor.ts +80 -0
- package/composables/useWorkOrder.ts +3 -3
- package/package.json +1 -1
- package/plugins/vuetify.ts +6 -1
- package/types/building.d.ts +19 -0
- package/types/camera.d.ts +31 -0
- package/types/people.d.ts +22 -0
- package/types/select.d.ts +4 -0
- package/types/site.d.ts +10 -7
- package/types/visitor.d.ts +42 -0
- package/utils/phoneMasks.ts +1703 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card flat border="sm grey-lighten-2" color="grey-lighten-3" class="w-100">
|
|
3
|
+
<v-card-text>
|
|
4
|
+
<v-row no-gutters>
|
|
5
|
+
<v-col cols="10" no-gutters>
|
|
6
|
+
<v-row no-gutters class="ga-1">
|
|
7
|
+
<template v-for="(label, key) in displayFields" :key="key">
|
|
8
|
+
<v-col v-if="member[key]" cols="12">
|
|
9
|
+
<span class="d-flex ga-3 align-center"><strong>{{ label }}:</strong> {{ member[key]
|
|
10
|
+
}}</span>
|
|
11
|
+
</v-col>
|
|
12
|
+
</template>
|
|
13
|
+
</v-row>
|
|
14
|
+
</v-col>
|
|
15
|
+
<v-col cols="2" class="d-flex align-center justify-center">
|
|
16
|
+
<v-btn icon="mdi-trash-outline" size="small" color="red" @click="emit('remove')" />
|
|
17
|
+
</v-col>
|
|
18
|
+
</v-row>
|
|
19
|
+
</v-card-text>
|
|
20
|
+
</v-card>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
|
|
25
|
+
const prop = defineProps({
|
|
26
|
+
member: {
|
|
27
|
+
type: Object as PropType<TMemberInfo>,
|
|
28
|
+
required: true
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits(['remove'])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
const displayFields: Record<keyof TMemberInfo, string> = {
|
|
36
|
+
name: "Name",
|
|
37
|
+
nric: "NRIC",
|
|
38
|
+
visitorPass: "Pass",
|
|
39
|
+
contact: "Contact"
|
|
40
|
+
} as const
|
|
41
|
+
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<style scoped></style>
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
<v-row dense class="my-1 pr-4">
|
|
63
63
|
<v-col cols="6" class="py-1"><strong>Category:</strong></v-col>
|
|
64
64
|
<v-col cols="6" class="py-1 text-right text-capitalize">{{
|
|
65
|
-
item.
|
|
65
|
+
item.category
|
|
66
66
|
}}</v-col>
|
|
67
67
|
</v-row>
|
|
68
68
|
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
|
|
187
187
|
<WorkOrderCreate
|
|
188
188
|
v-model="showCreateDialog"
|
|
189
|
-
|
|
189
|
+
created-from="feedback"
|
|
190
190
|
:work-order="_workOrder"
|
|
191
191
|
@update:work-order="(val: TWorkOrderCreate) => (_workOrder = val)"
|
|
192
192
|
:is-edit-mode="isEditMode"
|
|
@@ -269,20 +269,37 @@ const _workOrder = ref<TWorkOrderCreate>({
|
|
|
269
269
|
const serviceProviders = ref<
|
|
270
270
|
Array<{ title: string; value: string; subtitle: string }>
|
|
271
271
|
>([]);
|
|
272
|
-
const { getAll: getAllServiceProvider } = useServiceProvider();
|
|
273
|
-
|
|
274
|
-
const { data: getAllReq } = useLazyAsyncData("get-all-service-providers", () =>
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
272
|
+
// const { getAll: getAllServiceProvider } = useServiceProvider();
|
|
273
|
+
|
|
274
|
+
// const { data: getAllReq } = useLazyAsyncData("get-all-service-providers", () =>
|
|
275
|
+
// getAllServiceProvider({
|
|
276
|
+
// siteId: useRoute().params.site as string,
|
|
277
|
+
// })
|
|
278
|
+
// );
|
|
279
|
+
|
|
280
|
+
// watchEffect(() => {
|
|
281
|
+
// if (getAllReq.value) {
|
|
282
|
+
// serviceProviders.value = getAllReq.value.items.map((i: any) => ({
|
|
283
|
+
// title: i.nature.replace(/_/g, " "),
|
|
284
|
+
// value: i.serviceProviderOrgId,
|
|
285
|
+
// subtitle: i.name,
|
|
286
|
+
// }));
|
|
287
|
+
// }
|
|
288
|
+
// });
|
|
289
|
+
|
|
290
|
+
const { getBySiteAsServiceProvider } = useCustomerSite();
|
|
291
|
+
|
|
292
|
+
const { data: getAllReq } = useLazyAsyncData(
|
|
293
|
+
"get-by-site-as-service-provider",
|
|
294
|
+
() => getBySiteAsServiceProvider(useRoute().params.site as string)
|
|
278
295
|
);
|
|
279
296
|
|
|
280
297
|
watchEffect(() => {
|
|
281
298
|
if (getAllReq.value) {
|
|
282
|
-
serviceProviders.value = getAllReq.value.
|
|
299
|
+
serviceProviders.value = getAllReq.value.map((i: any) => ({
|
|
283
300
|
title: i.nature.replace(/_/g, " "),
|
|
284
|
-
|
|
285
|
-
|
|
301
|
+
subtitle: i.title,
|
|
302
|
+
value: i._id.org,
|
|
286
303
|
}));
|
|
287
304
|
}
|
|
288
305
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card width="100%" :disabled="loading" :loading="loading">
|
|
3
|
+
<v-card-text style="max-height: 100vh; overflow-y: auto" class="pa-5 my-5 px-7 text-center">
|
|
4
|
+
<span> {{ promptTitle }}</span>
|
|
5
|
+
|
|
6
|
+
<span v-if="message" class="text-error mt-2">
|
|
7
|
+
{{ message }} Do you want to delete anyway?
|
|
8
|
+
</span>
|
|
9
|
+
</v-card-text>
|
|
10
|
+
|
|
11
|
+
<v-toolbar class="pa-0" density="compact">
|
|
12
|
+
<v-row no-gutters>
|
|
13
|
+
<v-col cols="6" class="pa-0">
|
|
14
|
+
<v-btn block variant="text" class="text-none" size="large" tile @click="emit('close')"
|
|
15
|
+
height="48">
|
|
16
|
+
Cancel
|
|
17
|
+
</v-btn>
|
|
18
|
+
</v-col>
|
|
19
|
+
|
|
20
|
+
<v-col cols="6" class="pa-0">
|
|
21
|
+
<v-btn block tile variant="flat" class="text-none" size="large" height="48" color="black"
|
|
22
|
+
@click="emit('delete')">
|
|
23
|
+
Delete
|
|
24
|
+
</v-btn>
|
|
25
|
+
</v-col>
|
|
26
|
+
</v-row>
|
|
27
|
+
</v-toolbar>
|
|
28
|
+
</v-card>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<script setup lang="ts">
|
|
32
|
+
const props = defineProps({
|
|
33
|
+
message: {
|
|
34
|
+
type: String,
|
|
35
|
+
default: ""
|
|
36
|
+
},
|
|
37
|
+
promptTitle: {
|
|
38
|
+
type: String,
|
|
39
|
+
default: "Are you sure want to delete this? "
|
|
40
|
+
},
|
|
41
|
+
loading: {
|
|
42
|
+
type: Boolean,
|
|
43
|
+
default: false
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const emit = defineEmits(["close", "delete"])
|
|
48
|
+
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<style scoped></style>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<v-card width="100%">
|
|
3
|
+
<v-toolbar>
|
|
4
|
+
<template v-if="$slots.header">
|
|
5
|
+
<slot name="header" :title="title" :onClose="onClose" />
|
|
6
|
+
</template>
|
|
7
|
+
<template v-else>
|
|
8
|
+
<v-row no-gutters class="fill-height px-6" align="center">
|
|
9
|
+
<span class="font-weight-bold text-h5 text-capitalize">{{
|
|
10
|
+
title
|
|
11
|
+
}}</span>
|
|
12
|
+
</v-row>
|
|
13
|
+
</template>
|
|
14
|
+
</v-toolbar>
|
|
15
|
+
|
|
16
|
+
<v-card-text style="max-height: 100vh; overflow-y: auto" class="pb-0">
|
|
17
|
+
<slot name="content" />
|
|
18
|
+
</v-card-text>
|
|
19
|
+
|
|
20
|
+
<v-toolbar class="pa-0" density="compact">
|
|
21
|
+
<v-row no-gutters>
|
|
22
|
+
<v-col cols="6" class="pa-0">
|
|
23
|
+
<v-btn
|
|
24
|
+
block
|
|
25
|
+
variant="text"
|
|
26
|
+
class="text-none"
|
|
27
|
+
size="large"
|
|
28
|
+
@click="emit('close')"
|
|
29
|
+
height="48"
|
|
30
|
+
>
|
|
31
|
+
Close
|
|
32
|
+
</v-btn>
|
|
33
|
+
</v-col>
|
|
34
|
+
|
|
35
|
+
<v-col cols="6" class="pa-0" >
|
|
36
|
+
<v-menu contained>
|
|
37
|
+
<template #activator="{ props }">
|
|
38
|
+
<v-btn
|
|
39
|
+
block
|
|
40
|
+
variant="flat"
|
|
41
|
+
color="black"
|
|
42
|
+
class="text-none"
|
|
43
|
+
height="48"
|
|
44
|
+
v-bind="props"
|
|
45
|
+
tile
|
|
46
|
+
>
|
|
47
|
+
More actions
|
|
48
|
+
</v-btn>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<v-list class="pa-0">
|
|
52
|
+
<v-list-item v-if="canUpdate" @click="emit('edit')">
|
|
53
|
+
<v-list-item-title class="text-subtitle-2">
|
|
54
|
+
{{ editButtonLabel }}
|
|
55
|
+
</v-list-item-title>
|
|
56
|
+
</v-list-item>
|
|
57
|
+
|
|
58
|
+
<v-list-item v-if="canDelete" @click="emit('delete')" class="text-red">
|
|
59
|
+
<v-list-item-title class="text-subtitle-2">
|
|
60
|
+
{{ deleteButtonLabel }}
|
|
61
|
+
</v-list-item-title>
|
|
62
|
+
</v-list-item>
|
|
63
|
+
</v-list>
|
|
64
|
+
</v-menu>
|
|
65
|
+
</v-col>
|
|
66
|
+
</v-row>
|
|
67
|
+
</v-toolbar>
|
|
68
|
+
</v-card>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<script setup lang="ts">
|
|
72
|
+
const prop = defineProps({
|
|
73
|
+
canUpdate: {
|
|
74
|
+
type: Boolean,
|
|
75
|
+
default: true,
|
|
76
|
+
},
|
|
77
|
+
canDelete: {
|
|
78
|
+
type: Boolean,
|
|
79
|
+
default: true,
|
|
80
|
+
},
|
|
81
|
+
editButtonLabel: {
|
|
82
|
+
type: String,
|
|
83
|
+
default: "Edit",
|
|
84
|
+
},
|
|
85
|
+
deleteButtonLabel: {
|
|
86
|
+
type: String,
|
|
87
|
+
default: "Delete",
|
|
88
|
+
},
|
|
89
|
+
title: {
|
|
90
|
+
type: String,
|
|
91
|
+
default: "Details",
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const emit = defineEmits(["close", "edit", "delete"]);
|
|
96
|
+
const { canUpdate, editButtonLabel, deleteButtonLabel, title } = prop;
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<style scoped></style>
|
|
@@ -15,13 +15,18 @@
|
|
|
15
15
|
@errored="onImageError"
|
|
16
16
|
/>
|
|
17
17
|
|
|
18
|
-
<v-
|
|
18
|
+
<v-autocomplete
|
|
19
|
+
v-model="localFeedback.subject"
|
|
20
|
+
:items="subjects"
|
|
21
|
+
item-title="title"
|
|
22
|
+
item-value="value"
|
|
23
|
+
item-props
|
|
19
24
|
label="Subject"
|
|
20
25
|
variant="outlined"
|
|
21
26
|
density="compact"
|
|
22
|
-
v-model="localFeedback.subject"
|
|
23
27
|
class="mb-2"
|
|
24
|
-
|
|
28
|
+
clearable
|
|
29
|
+
:custom-filter="customFilter"
|
|
25
30
|
/>
|
|
26
31
|
|
|
27
32
|
<v-autocomplete
|
|
@@ -133,4 +138,13 @@ function closeDialog() {
|
|
|
133
138
|
function submitFeedback() {
|
|
134
139
|
emit("submit", { ...localFeedback.value });
|
|
135
140
|
}
|
|
141
|
+
|
|
142
|
+
const { subjects } = useLocal();
|
|
143
|
+
|
|
144
|
+
const customFilter = (value: string, query: string, item: any) => {
|
|
145
|
+
const title = item?.raw?.title?.toLowerCase() || "";
|
|
146
|
+
const subtitle = item?.raw?.subtitle?.toLowerCase() || "";
|
|
147
|
+
const search = query.toLowerCase();
|
|
148
|
+
return title.includes(search) || subtitle.includes(search);
|
|
149
|
+
};
|
|
136
150
|
</script>
|
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="feedback-detail-wrapper">
|
|
3
3
|
<v-row no-gutters class="fill-height">
|
|
4
|
-
<!-- <v-col cols="12" xl="3" lg="4" md="4" class="fill-height">
|
|
5
|
-
<div class="panel-container border-e">
|
|
6
|
-
<ChatNavigation
|
|
7
|
-
:title="'Feedbacks'"
|
|
8
|
-
:items="items"
|
|
9
|
-
@select="handleSelectFeedback"
|
|
10
|
-
@search="_getFeedbacks"
|
|
11
|
-
/>
|
|
12
|
-
</div>
|
|
13
|
-
</v-col> -->
|
|
14
4
|
|
|
15
5
|
<v-col cols="12" xl="7" lg="7" md="7" class="fill-height">
|
|
16
6
|
<div class="panel-container border-e">
|
|
@@ -179,7 +169,6 @@ const {
|
|
|
179
169
|
} = useLazyAsyncData("get-all-feedbacks", () =>
|
|
180
170
|
_getFeedbacks({
|
|
181
171
|
page: page.value,
|
|
182
|
-
organization: route.params.org as string,
|
|
183
172
|
site: route.params.site as string,
|
|
184
173
|
})
|
|
185
174
|
);
|
|
@@ -164,6 +164,10 @@ const props = defineProps({
|
|
|
164
164
|
type: Boolean,
|
|
165
165
|
default: false,
|
|
166
166
|
},
|
|
167
|
+
category: {
|
|
168
|
+
type: String,
|
|
169
|
+
default: "",
|
|
170
|
+
},
|
|
167
171
|
});
|
|
168
172
|
|
|
169
173
|
const isEditMode = ref(false);
|
|
@@ -179,20 +183,27 @@ const { getUserFromCookie } = useLocal();
|
|
|
179
183
|
const serviceProviders = ref<
|
|
180
184
|
Array<{ title: string; value: string; subtitle: string }>
|
|
181
185
|
>([]);
|
|
182
|
-
const { getAll: getAllServiceProvider } = useServiceProvider();
|
|
186
|
+
// const { getAll: getAllServiceProvider } = useServiceProvider();
|
|
183
187
|
|
|
184
|
-
const { data: getAllReq } = useLazyAsyncData("get-all-service-providers", () =>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
+
// const { data: getAllReq } = useLazyAsyncData("get-all-service-providers", () =>
|
|
189
|
+
// getAllServiceProvider({
|
|
190
|
+
// siteId: useRoute().params.site as string,
|
|
191
|
+
// })
|
|
192
|
+
// );
|
|
193
|
+
|
|
194
|
+
const { getBySiteAsServiceProvider } = useCustomerSite();
|
|
195
|
+
|
|
196
|
+
const { data: getAllReq } = useLazyAsyncData(
|
|
197
|
+
"get-by-site-as-service-provider",
|
|
198
|
+
() => getBySiteAsServiceProvider(useRoute().params.site as string)
|
|
188
199
|
);
|
|
189
200
|
|
|
190
201
|
watchEffect(() => {
|
|
191
202
|
if (getAllReq.value) {
|
|
192
|
-
serviceProviders.value = getAllReq.value.
|
|
203
|
+
serviceProviders.value = getAllReq.value.map((i: any) => ({
|
|
193
204
|
title: i.nature.replace(/_/g, " "),
|
|
194
|
-
|
|
195
|
-
|
|
205
|
+
subtitle: i.title,
|
|
206
|
+
value: i.nature,
|
|
196
207
|
}));
|
|
197
208
|
}
|
|
198
209
|
});
|
|
@@ -244,11 +255,11 @@ const {
|
|
|
244
255
|
data: getAllFeedbackReq,
|
|
245
256
|
status: getAllReqStatus,
|
|
246
257
|
refresh: getAllReqRefresh,
|
|
247
|
-
} = useLazyAsyncData("get-all-feedbacks", () =>
|
|
258
|
+
} = useLazyAsyncData("get-all-feedbacks-" + props.category, () =>
|
|
248
259
|
_getFeedbacks({
|
|
249
260
|
page: page.value,
|
|
250
|
-
organization: route.params.org as string,
|
|
251
261
|
site: route.params.site as string,
|
|
262
|
+
category: props.category,
|
|
252
263
|
})
|
|
253
264
|
);
|
|
254
265
|
|
|
@@ -268,7 +279,6 @@ async function updatePage(pageVal: any) {
|
|
|
268
279
|
page.value = pageVal;
|
|
269
280
|
const response = await _getFeedbacks({
|
|
270
281
|
page: page.value,
|
|
271
|
-
organization: route.params.org as string,
|
|
272
282
|
site: route.params.site as string,
|
|
273
283
|
});
|
|
274
284
|
if (response) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="d-flex flex-column">
|
|
3
3
|
<v-text-field v-bind="$attrs" ref="dateTimePickerRef" :model-value="dateTimeFormattedReadOnly"
|
|
4
|
-
placeholder="
|
|
4
|
+
:placeholder="placeholder" :rules="rules" style="z-index: 10" @click="openDatePicker">
|
|
5
5
|
<template #append-inner>
|
|
6
6
|
<v-icon icon="mdi-calendar" @click.stop="openDatePicker" />
|
|
7
7
|
</template>
|
|
@@ -20,6 +20,10 @@ const prop = defineProps({
|
|
|
20
20
|
type: Array as PropType<Array<any>>,
|
|
21
21
|
default: () => []
|
|
22
22
|
},
|
|
23
|
+
placeholder: {
|
|
24
|
+
type: String,
|
|
25
|
+
default: 'MM/DD/YYYY, HH:MM AM/PM'
|
|
26
|
+
}
|
|
23
27
|
})
|
|
24
28
|
|
|
25
29
|
const { formatDateISO8601 } = useUtils()
|
|
@@ -93,15 +97,16 @@ watch(dateTime, (dateVal) => {
|
|
|
93
97
|
|
|
94
98
|
}, { immediate: false })
|
|
95
99
|
|
|
100
|
+
watch(dateTimeUTC, () => {
|
|
101
|
+
handleInitialDate()
|
|
102
|
+
}, { immediate: true})
|
|
96
103
|
|
|
97
|
-
onMounted(async () => {
|
|
98
104
|
|
|
99
|
-
|
|
105
|
+
onMounted(async () => {
|
|
100
106
|
await nextTick()
|
|
101
107
|
isInitialLoad.value = false
|
|
102
|
-
|
|
103
108
|
// Wait until Vuetify renders its internal input
|
|
104
|
-
const nativeInput = dateTimePickerRef.value?.$el?.querySelector('input')
|
|
109
|
+
const nativeInput = (dateTimePickerRef.value as any)?.$el?.querySelector('input')
|
|
105
110
|
if (nativeInput) {
|
|
106
111
|
nativeInput.addEventListener('click', (e: MouseEvent) => {
|
|
107
112
|
e.stopPropagation()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<v-row no-gutters class="w-100 pb-5" @click="resetErrorMessage">
|
|
3
3
|
<v-file-upload v-model="uploadFiles" density="compact" @update:model-value="handleUpdateValue"
|
|
4
|
-
:loading="processing" :disabled="processing" :height="height" title="
|
|
4
|
+
:loading="processing" :disabled="processing" :height="height" :title="title" :accept="accept"
|
|
5
5
|
name="upload_images" class="text-caption w-100" clearable :multiple="multiple">
|
|
6
6
|
<template v-slot:item="{ props: itemProps, file }">
|
|
7
7
|
<v-file-upload-item v-bind="itemProps" lines="one" nav>
|
|
@@ -22,93 +22,136 @@
|
|
|
22
22
|
</template>
|
|
23
23
|
|
|
24
24
|
<script setup lang="ts">
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
25
|
+
import { nextTick, ref, onMounted, watch } from 'vue'
|
|
26
|
+
|
|
27
|
+
const props = defineProps({
|
|
28
|
+
height: {
|
|
29
|
+
type: [Number, String],
|
|
30
|
+
default: 68,
|
|
31
|
+
},
|
|
32
|
+
multiple: {
|
|
33
|
+
type: Boolean,
|
|
34
|
+
default: false,
|
|
35
|
+
},
|
|
36
|
+
maxLength: {
|
|
37
|
+
type: Number,
|
|
38
|
+
default: 10,
|
|
39
|
+
},
|
|
40
|
+
title: {
|
|
41
|
+
type: String,
|
|
42
|
+
default: 'Upload Images'
|
|
43
|
+
},
|
|
44
|
+
accept: {
|
|
45
|
+
type: String,
|
|
46
|
+
default: "image/*"
|
|
47
|
+
}
|
|
41
48
|
})
|
|
42
49
|
|
|
43
50
|
const { addFile, deleteFile, getFileUrl, urlToFile } = useFile()
|
|
44
51
|
|
|
52
|
+
// The parent v-model binding
|
|
53
|
+
const idsArray = defineModel<string[]>({ default: [] })
|
|
54
|
+
|
|
45
55
|
const uploadFiles = ref<File[]>([])
|
|
46
|
-
const filesCollection =
|
|
47
|
-
const
|
|
48
|
-
const errorMessage = ref('');
|
|
56
|
+
const filesCollection = ref<{ file: File; id: string }[]>([])
|
|
57
|
+
const errorMessage = ref('')
|
|
49
58
|
const processing = ref(false)
|
|
50
59
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
function fileKey(f: File) {
|
|
61
|
+
return `${f.name}_${f.size}_${f.lastModified}`
|
|
62
|
+
}
|
|
55
63
|
|
|
56
64
|
async function handleRemove(removedFile: File) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
const key = fileKey(removedFile)
|
|
66
|
+
const arr = [...filesCollection.value]
|
|
67
|
+
|
|
68
|
+
const removedItem = arr.find((item) => fileKey(item.file) === key)
|
|
69
|
+
if (!removedItem) return
|
|
70
|
+
|
|
71
|
+
filesCollection.value = arr.filter((item) => item.id !== removedItem.id)
|
|
72
|
+
uploadFiles.value = uploadFiles.value.filter((f) => fileKey(f) !== key)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
idsArray.value = filesCollection.value.map((x) => x.id)
|
|
60
76
|
|
|
61
|
-
uploadFiles.value = uploadFiles.value.filter(f => fileKey(f) !== fileKey(removedFile))
|
|
62
|
-
filesCollection.value = arr.filter(item => item.id !== fileId)
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
async function handleUpdateValue(value: File[]) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if ((existingLength + value.length) > max) {
|
|
71
|
-
value = value.slice(0, (max - existingLength))
|
|
72
|
-
errorMessage.value = `Max value of allowed image is ${max}`
|
|
73
|
-
}
|
|
74
|
-
uploadFiles.value = []
|
|
75
|
-
processing.value = true
|
|
76
|
-
// Determine which files are newly added or removed
|
|
77
|
-
const fileKey = (f: File) => `${f.name}_${f.size}_${f.lastModified}`
|
|
80
|
+
await nextTick()
|
|
81
|
+
const max = props.maxLength
|
|
82
|
+
const existingLength = filesCollection.value.length
|
|
83
|
+
errorMessage.value = ''
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
if (existingLength + value.length > max) {
|
|
86
|
+
value = value.slice(0, max - existingLength)
|
|
87
|
+
errorMessage.value = `Max allowed images is ${max}`
|
|
88
|
+
}
|
|
82
89
|
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
const collectionKeys = filesCollection.value.map((x) => fileKey(x.file))
|
|
91
|
+
const addedFiles = value.filter((f) => !collectionKeys.includes(fileKey(f)))
|
|
92
|
+
|
|
93
|
+
processing.value = true
|
|
94
|
+
try {
|
|
85
95
|
for (const file of addedFiles) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
} catch (err) {
|
|
92
|
-
console.error("Upload failed", err)
|
|
93
|
-
errorMessage.value = `Failed to upload ${file.name}`
|
|
94
|
-
} finally {
|
|
95
|
-
processing.value = false
|
|
96
|
-
uploadFiles.value = filesCollection.value.map(x => x.file)
|
|
97
|
-
}
|
|
96
|
+
const res = await addFile(file) // should return { id, url }
|
|
97
|
+
if (res?.id) {
|
|
98
|
+
filesCollection.value.push({ file, id: res.id })
|
|
99
|
+
}
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
uploadFiles.value = filesCollection.value.map((x) => x.file)
|
|
103
|
+
idsArray.value = filesCollection.value.map((x) => x.id)
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error('Upload failed', err)
|
|
106
|
+
errorMessage.value = 'Failed to upload some files.'
|
|
107
|
+
} finally {
|
|
108
|
+
processing.value = false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async function loadFilesFromIds(ids: string[]) {
|
|
114
|
+
const result: { file: File; id: string }[] = []
|
|
115
|
+
for (const id of ids) {
|
|
116
|
+
try {
|
|
117
|
+
const url = await getFileUrl(id)
|
|
118
|
+
const name = decodeURIComponent(url.split('/').pop() || `file_${id}`)
|
|
119
|
+
const file = await urlToFile(url, name)
|
|
120
|
+
result.push({ file, id })
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.warn('Failed to load file from ID:', id, err)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return result
|
|
100
126
|
}
|
|
101
127
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
128
|
+
|
|
129
|
+
function resetErrorMessage() {
|
|
130
|
+
errorMessage.value = ''
|
|
105
131
|
}
|
|
106
132
|
|
|
107
|
-
|
|
108
|
-
|
|
133
|
+
|
|
134
|
+
onMounted(async () => {
|
|
135
|
+
if (idsArray.value.length > 0) {
|
|
136
|
+
processing.value = true
|
|
137
|
+
const loaded = await loadFilesFromIds(idsArray.value)
|
|
138
|
+
filesCollection.value = loaded
|
|
139
|
+
uploadFiles.value = loaded.map((x) => x.file)
|
|
140
|
+
processing.value = false
|
|
141
|
+
}
|
|
109
142
|
})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
watch(
|
|
146
|
+
filesCollection,
|
|
147
|
+
(newVal) => {
|
|
148
|
+
idsArray.value = newVal.map((x) => x.id)
|
|
149
|
+
},
|
|
150
|
+
{ deep: true }
|
|
151
|
+
)
|
|
110
152
|
</script>
|
|
111
153
|
|
|
154
|
+
|
|
112
155
|
<style scoped>
|
|
113
156
|
* :deep(.v-file-upload-title) {
|
|
114
157
|
font-size: 1rem;
|