@7365admin1/layer-common 1.11.19 → 1.11.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/components/AddPassKeyToVisitor.vue +114 -53
- package/components/AreaChecklistHistoryMain.vue +29 -1
- package/components/BulletinBoardView.vue +158 -16
- package/components/CleaningScheduleMain.vue +2 -2
- package/components/Input/DateTimePicker.vue +45 -9
- package/components/OvernightParkingAvailability.vue +291 -155
- package/components/ScanVisitorQRCode.vue +157 -0
- package/components/ScheduleAreaMain.vue +6 -6
- package/components/SiteSettings.vue +303 -243
- package/components/TableMain.vue +72 -21
- package/components/VisitorManagement.vue +656 -234
- package/composables/useFeedback.ts +1 -1
- package/composables/useOrg.ts +16 -0
- package/composables/useSiteSettings.ts +30 -1
- package/package.json +1 -1
- package/plugins/html5-qrcode.client.js +8 -0
- package/types/overnight-parking.d.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 1.11.21
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 17a59f6: Update Layer-common
|
|
8
|
+
|
|
9
|
+
## 1.11.20
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- e9bd022: fix feedbacks page when refreshing or reloading the page
|
|
14
|
+
|
|
3
15
|
## 1.11.19
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
<v-card width="100%" :loading="processing">
|
|
3
3
|
<v-toolbar>
|
|
4
4
|
<v-row no-gutters class="fill-height px-6 d-flex justify-space-between align-center" align="center">
|
|
5
|
-
<span class="font-weight-bold text-subtitle-1">Assign Pass &
|
|
5
|
+
<span class="font-weight-bold text-subtitle-1">{{ isEditMode ? 'Edit' : 'Assign' }} Pass &
|
|
6
|
+
Keys</span>
|
|
6
7
|
<ButtonClose @click="emit('close')" />
|
|
7
8
|
</v-row>
|
|
8
9
|
</v-toolbar>
|
|
@@ -11,25 +12,28 @@
|
|
|
11
12
|
<v-row no-gutters class="ga-1">
|
|
12
13
|
<v-col cols="12">
|
|
13
14
|
<span class="text-subtitle-2 text-medium-emphasis">
|
|
14
|
-
|
|
15
|
+
Name: {{ prop.visitor.name }}
|
|
15
16
|
</span>
|
|
16
17
|
</v-col>
|
|
17
18
|
|
|
19
|
+
<!-- Pass section -->
|
|
18
20
|
<v-col cols="12" class="mt-3">
|
|
19
21
|
<InputLabel title="Pass" />
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
22
|
+
|
|
23
|
+
<!-- Existing pass -->
|
|
24
|
+
<div v-if="existingPass" class="d-flex align-center ga-2 mb-2">
|
|
25
|
+
<v-chip prepend-icon="mdi-card-bulleted-outline" color="blue" variant="tonal" size="small"
|
|
26
|
+
closable @click:close="existingPass = null">
|
|
27
|
+
{{ existingPass.prefixAndName }}
|
|
28
|
+
</v-chip>
|
|
29
|
+
<span class="text-caption text-medium-emphasis">(current)</span>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<!-- New pass selector -->
|
|
33
|
+
<v-autocomplete v-if="!existingPass" v-model="selectedPass" v-model:search="passInput"
|
|
34
|
+
:hide-no-data="false" :items="passItems" item-title="prefixAndName" item-value="_id"
|
|
35
|
+
variant="outlined" hide-details density="compact" small-chips :loading="fetchPassesPending"
|
|
36
|
+
clearable>
|
|
33
37
|
<template v-slot:chip="{ props: chipProps, item }">
|
|
34
38
|
<v-chip v-if="selectedPass" v-bind="chipProps" prepend-icon="mdi-card-bulleted-outline"
|
|
35
39
|
:text="item.raw?.prefixAndName" />
|
|
@@ -42,22 +46,24 @@
|
|
|
42
46
|
</v-autocomplete>
|
|
43
47
|
</v-col>
|
|
44
48
|
|
|
49
|
+
<!-- Keys section -->
|
|
45
50
|
<v-col v-if="showKeys" cols="12" class="mt-3">
|
|
46
51
|
<InputLabel title="Keys" />
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
:
|
|
60
|
-
|
|
52
|
+
|
|
53
|
+
<!-- Existing keys -->
|
|
54
|
+
<div v-if="existingKeys.length > 0" class="d-flex flex-wrap ga-1 mb-2">
|
|
55
|
+
<v-chip v-for="key in existingKeys" :key="key.keyId" prepend-icon="mdi-key" color="orange"
|
|
56
|
+
variant="tonal" size="small" closable @click:close="removeExistingKey(key.keyId)">
|
|
57
|
+
{{ key.prefixAndName }}
|
|
58
|
+
</v-chip>
|
|
59
|
+
<span class="text-caption text-medium-emphasis align-self-center">(current)</span>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<!-- New keys selector -->
|
|
63
|
+
<v-autocomplete v-model="selectedKeys" v-model:search="keyInput" :hide-no-data="false"
|
|
64
|
+
:items="keyItems" item-title="prefixAndName" item-value="_id"
|
|
65
|
+
:label="existingKeys.length > 0 ? 'Add more keys' : undefined" multiple variant="outlined"
|
|
66
|
+
hide-details density="compact" small-chips :loading="fetchKeysPending">
|
|
61
67
|
<template v-slot:chip="{ props: chipProps, item }">
|
|
62
68
|
<v-chip v-if="selectedKeys.length > 0" v-bind="chipProps" prepend-icon="mdi-key"
|
|
63
69
|
:text="item.raw?.prefixAndName" />
|
|
@@ -82,13 +88,9 @@
|
|
|
82
88
|
<v-btn tile block variant="text" class="text-none" size="48" text="Cancel" @click="emit('close')" />
|
|
83
89
|
</v-col>
|
|
84
90
|
<v-col cols="6">
|
|
85
|
-
<v-btn
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
:disabled="!selectedPass && selectedKeys.length === 0"
|
|
89
|
-
:loading="processing"
|
|
90
|
-
@click="handleSubmit"
|
|
91
|
-
/>
|
|
91
|
+
<v-btn tile block variant="flat" color="black" class="text-none" size="48"
|
|
92
|
+
:text="isEditMode ? 'Save' : 'Assign'" :disabled="!canSubmit" :loading="processing"
|
|
93
|
+
@click="handleSubmit" />
|
|
92
94
|
</v-col>
|
|
93
95
|
</v-row>
|
|
94
96
|
</v-toolbar>
|
|
@@ -109,13 +111,9 @@ const prop = defineProps({
|
|
|
109
111
|
type: String,
|
|
110
112
|
required: true,
|
|
111
113
|
},
|
|
112
|
-
|
|
113
|
-
type: String as PropType<
|
|
114
|
-
|
|
115
|
-
},
|
|
116
|
-
contractorType: {
|
|
117
|
-
type: String,
|
|
118
|
-
default: '',
|
|
114
|
+
mode: {
|
|
115
|
+
type: String as PropType<'add' | 'edit'>,
|
|
116
|
+
default: 'add',
|
|
119
117
|
},
|
|
120
118
|
})
|
|
121
119
|
|
|
@@ -130,6 +128,7 @@ const { updateVisitor } = useVisitor()
|
|
|
130
128
|
const processing = ref(false)
|
|
131
129
|
const errorMessage = ref('')
|
|
132
130
|
|
|
131
|
+
// New selections
|
|
133
132
|
const selectedPass = ref<string>('')
|
|
134
133
|
const selectedKeys = ref<string[]>([])
|
|
135
134
|
const passInput = ref('')
|
|
@@ -137,16 +136,46 @@ const keyInput = ref('')
|
|
|
137
136
|
const passItems = ref<TPassKey[]>([])
|
|
138
137
|
const keyItems = ref<TPassKey[]>([])
|
|
139
138
|
|
|
139
|
+
// Existing pass/keys (editable)
|
|
140
|
+
const existingPass = ref<{ keyId: string; prefixAndName: string } | null>(null)
|
|
141
|
+
const existingKeys = ref<{ keyId: string; prefixAndName: string }[]>([])
|
|
142
|
+
|
|
143
|
+
const isEditMode = computed(() => prop.mode === 'edit')
|
|
140
144
|
const showKeys = computed(() => prop.visitor.type === 'contractor')
|
|
141
145
|
|
|
142
146
|
const passTypesComputed = computed(() => {
|
|
143
|
-
|
|
144
|
-
|
|
147
|
+
const type = prop.visitor.type
|
|
148
|
+
const contractorType = (prop.visitor as any).contractorType
|
|
149
|
+
if (type === 'contractor') {
|
|
150
|
+
return contractorType === 'property-agent' ? ['agent-pass'] : ['contractor-pass']
|
|
145
151
|
}
|
|
146
152
|
return ['visitor-pass']
|
|
147
153
|
})
|
|
148
154
|
|
|
149
|
-
|
|
155
|
+
// Initialise existing values in edit mode
|
|
156
|
+
onMounted(() => {
|
|
157
|
+
if (isEditMode.value) {
|
|
158
|
+
const vPass = prop.visitor.visitorPass
|
|
159
|
+
if (Array.isArray(vPass) && vPass.length > 0) {
|
|
160
|
+
const p = vPass[0] as any
|
|
161
|
+
existingPass.value = { keyId: p.keyId, prefixAndName: p.prefixAndName }
|
|
162
|
+
}
|
|
163
|
+
const vKeys = prop.visitor.passKeys
|
|
164
|
+
if (Array.isArray(vKeys) && vKeys.length > 0) {
|
|
165
|
+
existingKeys.value = (vKeys as any[]).map(k => ({ keyId: k.keyId, prefixAndName: k.prefixAndName }))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
function removeExistingKey(keyId: string) {
|
|
171
|
+
existingKeys.value = existingKeys.value.filter(k => k.keyId !== keyId)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Exclude already-held pass/keys from selectable lists
|
|
175
|
+
const existingPassId = computed(() => existingPass.value?.keyId)
|
|
176
|
+
const existingKeyIds = computed(() => existingKeys.value.map(k => k.keyId))
|
|
177
|
+
|
|
178
|
+
const { data: passesData, refresh: refreshPasses, pending: fetchPassesPending } = await useLazyAsyncData(
|
|
150
179
|
`add-pass-key-visitor-passes-${prop.visitor._id}`,
|
|
151
180
|
() => getPassKeysByPageSearch({
|
|
152
181
|
search: passInput.value,
|
|
@@ -158,7 +187,7 @@ const { data: passesData, pending: fetchPassesPending } = await useLazyAsyncData
|
|
|
158
187
|
})
|
|
159
188
|
)
|
|
160
189
|
|
|
161
|
-
const { data: keysData, pending: fetchKeysPending } = await useLazyAsyncData(
|
|
190
|
+
const { data: keysData, refresh: refreshKeys, pending: fetchKeysPending } = await useLazyAsyncData(
|
|
162
191
|
`add-pass-key-visitor-keys-${prop.visitor._id}`,
|
|
163
192
|
() => getPassKeysByPageSearch({
|
|
164
193
|
search: keyInput.value,
|
|
@@ -171,11 +200,30 @@ const { data: keysData, pending: fetchKeysPending } = await useLazyAsyncData(
|
|
|
171
200
|
)
|
|
172
201
|
|
|
173
202
|
watch(passesData, (data: any) => {
|
|
174
|
-
|
|
203
|
+
const all: TPassKey[] = Array.isArray(data?.items) ? data.items : []
|
|
204
|
+
passItems.value = all.filter(p => p._id !== existingPassId.value)
|
|
175
205
|
})
|
|
176
206
|
|
|
177
207
|
watch(keysData, (data: any) => {
|
|
178
|
-
|
|
208
|
+
const all: TPassKey[] = Array.isArray(data?.items) ? data.items : []
|
|
209
|
+
keyItems.value = all.filter(k => !existingKeyIds.value.includes(k._id!))
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// Re-filter when existing items change
|
|
213
|
+
watch([existingPassId, existingKeyIds], () => {
|
|
214
|
+
const rawPasses = (passesData.value as any)?.items ?? []
|
|
215
|
+
passItems.value = rawPasses.filter((p: TPassKey) => p._id !== existingPassId.value)
|
|
216
|
+
const rawKeys = (keysData.value as any)?.items ?? []
|
|
217
|
+
keyItems.value = rawKeys.filter((k: TPassKey) => !existingKeyIds.value.includes(k._id!))
|
|
218
|
+
}, { deep: true })
|
|
219
|
+
|
|
220
|
+
const canSubmit = computed(() => {
|
|
221
|
+
if (isEditMode.value) {
|
|
222
|
+
// allow save if anything changed: existing items modified OR new items selected
|
|
223
|
+
return existingPass.value !== null || selectedPass.value ||
|
|
224
|
+
existingKeys.value.length > 0 || selectedKeys.value.length > 0
|
|
225
|
+
}
|
|
226
|
+
return !!selectedPass.value || selectedKeys.value.length > 0
|
|
179
227
|
})
|
|
180
228
|
|
|
181
229
|
async function handleSubmit() {
|
|
@@ -186,18 +234,30 @@ async function handleSubmit() {
|
|
|
186
234
|
try {
|
|
187
235
|
const payload: Partial<TVisitorPayload> = {}
|
|
188
236
|
|
|
237
|
+
// Build final pass list: kept existing + newly selected
|
|
238
|
+
const finalPasses: { keyId: string; status: string }[] = []
|
|
239
|
+
if (existingPass.value) {
|
|
240
|
+
finalPasses.push({ keyId: existingPass.value.keyId, status: 'In Use' })
|
|
241
|
+
}
|
|
189
242
|
if (selectedPass.value) {
|
|
190
|
-
|
|
243
|
+
finalPasses.push({ keyId: selectedPass.value, status: 'In Use' })
|
|
191
244
|
}
|
|
245
|
+
payload.visitorPass = finalPasses
|
|
192
246
|
|
|
193
|
-
|
|
194
|
-
|
|
247
|
+
// Build final keys list: kept existing + newly selected
|
|
248
|
+
if (showKeys.value) {
|
|
249
|
+
const finalKeys: { keyId: string; status: string }[] = [
|
|
250
|
+
...existingKeys.value.map(k => ({ keyId: k.keyId, status: 'In Use' })),
|
|
251
|
+
...selectedKeys.value.map(keyId => ({ keyId, status: 'In Use' })),
|
|
252
|
+
]
|
|
253
|
+
payload.passKeys = finalKeys
|
|
195
254
|
}
|
|
196
255
|
|
|
197
256
|
await updateVisitor(prop.visitor._id, payload)
|
|
198
257
|
emit('done')
|
|
258
|
+
emit('close')
|
|
199
259
|
} catch (error: any) {
|
|
200
|
-
errorMessage.value = error?.data?.message || 'Failed to
|
|
260
|
+
errorMessage.value = error?.data?.message || 'Failed to save pass & keys. Please try again.'
|
|
201
261
|
} finally {
|
|
202
262
|
processing.value = false
|
|
203
263
|
}
|
|
@@ -205,3 +265,4 @@ async function handleSubmit() {
|
|
|
205
265
|
</script>
|
|
206
266
|
|
|
207
267
|
<style scoped></style>
|
|
268
|
+
|
|
@@ -14,6 +14,22 @@
|
|
|
14
14
|
@refresh="getAreaChecklistHistoryRefresh"
|
|
15
15
|
@row-click="handleRowClick"
|
|
16
16
|
>
|
|
17
|
+
<template #actions>
|
|
18
|
+
<v-row class="w-100" align="center" no-gutters>
|
|
19
|
+
<v-col cols="auto">
|
|
20
|
+
<v-btn
|
|
21
|
+
variant="text"
|
|
22
|
+
color="primary"
|
|
23
|
+
class="text-none"
|
|
24
|
+
@click="back"
|
|
25
|
+
>
|
|
26
|
+
<v-icon left>mdi-arrow-left</v-icon>
|
|
27
|
+
Back
|
|
28
|
+
</v-btn>
|
|
29
|
+
</v-col>
|
|
30
|
+
</v-row>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
17
33
|
<template #extension>
|
|
18
34
|
<v-row no-gutters class="w-100 d-flex flex-column">
|
|
19
35
|
<v-card
|
|
@@ -84,7 +100,7 @@ const props = defineProps({
|
|
|
84
100
|
scheduleRoute: { type: String, default: "cleaning-schedule" },
|
|
85
101
|
});
|
|
86
102
|
|
|
87
|
-
const { formatDate } = useUtils();
|
|
103
|
+
const { formatDate, back, debounce } = useUtils();
|
|
88
104
|
|
|
89
105
|
const loading = ref(false);
|
|
90
106
|
const message = ref("");
|
|
@@ -160,6 +176,18 @@ watchEffect(() => {
|
|
|
160
176
|
}
|
|
161
177
|
});
|
|
162
178
|
|
|
179
|
+
const debouncedSearchRefresh = debounce(() => {
|
|
180
|
+
const wasPage = page.value;
|
|
181
|
+
page.value = 1;
|
|
182
|
+
if (wasPage === 1) {
|
|
183
|
+
getAreaChecklistHistoryRefresh();
|
|
184
|
+
}
|
|
185
|
+
}, 450);
|
|
186
|
+
|
|
187
|
+
watch(searchInput, () => {
|
|
188
|
+
debouncedSearchRefresh();
|
|
189
|
+
});
|
|
190
|
+
|
|
163
191
|
function handleRowClick(data: any) {
|
|
164
192
|
const item = data?.item ?? data;
|
|
165
193
|
const id = item?._id || item?.id || item?.areaId;
|
|
@@ -31,24 +31,119 @@
|
|
|
31
31
|
</template>
|
|
32
32
|
|
|
33
33
|
<v-col cols="12" v-if="prop.activeAnnouncement?.file?.length > 0" class="mb-5">
|
|
34
|
-
<v-row no-gutters
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
34
|
+
<v-row no-gutters>
|
|
35
|
+
<v-col class="font-weight-bold">Attachments:</v-col>
|
|
36
|
+
<v-col cols="9" class="px-10">
|
|
37
|
+
|
|
38
|
+
<template v-if="videosArray.length > 0">
|
|
39
|
+
<div class="text-caption text-medium-emphasis mb-1">Videos ({{ videosArray.length }})</div>
|
|
40
|
+
<v-list density="compact" class="pa-0 rounded-lg border mb-3">
|
|
41
|
+
<template v-for="(item, i) in videosArray" :key="item._id">
|
|
42
|
+
<v-divider v-if="i > 0" />
|
|
43
|
+
<v-list-item
|
|
44
|
+
:prepend-icon="getFileIcon(item.file.type)"
|
|
45
|
+
:title="item.file.name"
|
|
46
|
+
:subtitle="item.file.type"
|
|
47
|
+
class="cursor-pointer"
|
|
48
|
+
@click="openMediaViewer(item._id, 'media')"
|
|
49
|
+
>
|
|
50
|
+
<template #prepend>
|
|
51
|
+
<v-icon :color="getFileIconColor(item.file.type)" class="mr-3">{{ getFileIcon(item.file.type) }}</v-icon>
|
|
52
|
+
</template>
|
|
53
|
+
<template #append>
|
|
54
|
+
<v-icon size="16" color="grey-lighten-1">mdi-play-circle-outline</v-icon>
|
|
55
|
+
</template>
|
|
56
|
+
</v-list-item>
|
|
57
|
+
</template>
|
|
58
|
+
</v-list>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<template v-if="photosArray.length > 0">
|
|
62
|
+
<div class="text-caption text-medium-emphasis mb-1" :class="{ 'mt-3': videosArray.length > 0 }">Photos ({{ photosArray.length }})</div>
|
|
63
|
+
<v-list density="compact" class="pa-0 rounded-lg border mb-3">
|
|
64
|
+
<template v-for="(item, i) in photosArray" :key="item._id">
|
|
65
|
+
<v-divider v-if="i > 0" />
|
|
66
|
+
<v-list-item
|
|
67
|
+
class="cursor-pointer"
|
|
68
|
+
@click="openMediaViewer(item._id, 'media')"
|
|
69
|
+
>
|
|
70
|
+
<template #prepend>
|
|
71
|
+
<v-avatar size="36" rounded="sm" class="mr-3">
|
|
72
|
+
<v-img :src="getFileUrl(item._id)" cover>
|
|
73
|
+
<template #error>
|
|
74
|
+
<v-icon color="green" size="20">mdi-image</v-icon>
|
|
75
|
+
</template>
|
|
76
|
+
</v-img>
|
|
77
|
+
</v-avatar>
|
|
78
|
+
</template>
|
|
79
|
+
<v-list-item-title class="text-body-2">{{ item.file.name }}</v-list-item-title>
|
|
80
|
+
<v-list-item-subtitle class="text-caption">{{ item.file.type }}</v-list-item-subtitle>
|
|
81
|
+
<template #append>
|
|
82
|
+
<v-icon size="16" color="grey-lighten-1">mdi-eye-outline</v-icon>
|
|
83
|
+
</template>
|
|
84
|
+
</v-list-item>
|
|
85
|
+
</template>
|
|
86
|
+
</v-list>
|
|
87
|
+
</template>
|
|
88
|
+
|
|
89
|
+
<template v-if="filesArray.length > 0">
|
|
90
|
+
<div class="text-caption text-medium-emphasis mb-1" :class="{ 'mt-3': videosArray.length > 0 || photosArray.length > 0 }">Files ({{ filesArray.length }})</div>
|
|
91
|
+
<v-list density="compact" class="pa-0 rounded-lg border">
|
|
92
|
+
<template v-for="(item, i) in filesArray" :key="item._id">
|
|
93
|
+
<v-divider v-if="i > 0" />
|
|
94
|
+
<v-list-item
|
|
95
|
+
class="cursor-pointer"
|
|
96
|
+
@click="openFileViewer(item._id, item.file.name)"
|
|
97
|
+
>
|
|
98
|
+
<template #prepend>
|
|
99
|
+
<v-icon :color="getFileIconColor(item.file.type)" class="mr-3">{{ getFileIcon(item.file.type) }}</v-icon>
|
|
100
|
+
</template>
|
|
101
|
+
<v-list-item-title class="text-body-2">{{ item.file.name }}</v-list-item-title>
|
|
102
|
+
<v-list-item-subtitle class="text-caption">{{ item.file.type }}</v-list-item-subtitle>
|
|
103
|
+
<template #append>
|
|
104
|
+
<v-icon size="16" color="grey-lighten-1">mdi-open-in-new</v-icon>
|
|
105
|
+
</template>
|
|
106
|
+
</v-list-item>
|
|
107
|
+
</template>
|
|
108
|
+
</v-list>
|
|
109
|
+
</template>
|
|
110
|
+
|
|
111
|
+
</v-col>
|
|
50
112
|
</v-row>
|
|
51
113
|
</v-col>
|
|
114
|
+
|
|
115
|
+
<!-- Media Carousel (images + videos) -->
|
|
116
|
+
<ImageCarousel v-model="showCarousel" :active-file-id="activeFileId" :files="carouselFiles" />
|
|
117
|
+
|
|
118
|
+
<!-- File Viewer Dialog (PDF, docs, etc.) -->
|
|
119
|
+
<v-dialog v-model="showFileDialog" max-width="900" scrollable>
|
|
120
|
+
<v-card>
|
|
121
|
+
<v-toolbar density="compact">
|
|
122
|
+
<v-toolbar-title class="text-subtitle-2 text-truncate">{{ activeFileName }}</v-toolbar-title>
|
|
123
|
+
<v-spacer />
|
|
124
|
+
<v-btn icon="mdi-open-in-new" variant="text" size="small"
|
|
125
|
+
@click="openInNewTab(activeFileId)" />
|
|
126
|
+
<v-btn icon="mdi-close" variant="text" size="small"
|
|
127
|
+
@click="showFileDialog = false" />
|
|
128
|
+
</v-toolbar>
|
|
129
|
+
<v-card-text class="pa-0" style="height: 75vh;">
|
|
130
|
+
<iframe
|
|
131
|
+
:src="getFileUrl(activeFileId)"
|
|
132
|
+
width="100%"
|
|
133
|
+
height="100%"
|
|
134
|
+
style="border: none;"
|
|
135
|
+
:title="activeFileName"
|
|
136
|
+
/>
|
|
137
|
+
</v-card-text>
|
|
138
|
+
<v-card-actions>
|
|
139
|
+
<v-spacer />
|
|
140
|
+
<v-btn variant="text" class="text-none" @click="openInNewTab(activeFileId)">
|
|
141
|
+
<v-icon start>mdi-download</v-icon>
|
|
142
|
+
Open / Download
|
|
143
|
+
</v-btn>
|
|
144
|
+
</v-card-actions>
|
|
145
|
+
</v-card>
|
|
146
|
+
</v-dialog>
|
|
52
147
|
</v-row>
|
|
53
148
|
</v-card-text>
|
|
54
149
|
|
|
@@ -147,6 +242,53 @@ const photosArray = getByType('image/')
|
|
|
147
242
|
const videosArray = getByType('video/')
|
|
148
243
|
const filesArray = getByType('other')
|
|
149
244
|
|
|
245
|
+
/* Viewer state */
|
|
246
|
+
const showCarousel = ref(false)
|
|
247
|
+
const showFileDialog = ref(false)
|
|
248
|
+
const activeFileId = ref('')
|
|
249
|
+
const activeFileName = ref('')
|
|
250
|
+
const carouselFiles = ref<string[]>([])
|
|
251
|
+
|
|
252
|
+
function openMediaViewer(id: string, _type: 'media') {
|
|
253
|
+
activeFileId.value = id
|
|
254
|
+
carouselFiles.value = [
|
|
255
|
+
...photosArray.value.map(x => x._id),
|
|
256
|
+
...videosArray.value.map(x => x._id),
|
|
257
|
+
]
|
|
258
|
+
showCarousel.value = true
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function openFileViewer(id: string, name: string) {
|
|
262
|
+
activeFileId.value = id
|
|
263
|
+
activeFileName.value = name
|
|
264
|
+
showFileDialog.value = true
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function openInNewTab(id: string) {
|
|
268
|
+
window.open(getFileUrl(id), '_blank', 'noopener,noreferrer')
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function getFileIcon(mimeType: string): string {
|
|
272
|
+
if (mimeType.startsWith('image/')) return 'mdi-image'
|
|
273
|
+
if (mimeType.startsWith('video/')) return 'mdi-video'
|
|
274
|
+
if (mimeType === 'application/pdf') return 'mdi-file-pdf-box'
|
|
275
|
+
if (mimeType.includes('word') || mimeType.includes('document')) return 'mdi-file-word-box'
|
|
276
|
+
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return 'mdi-file-excel-box'
|
|
277
|
+
if (mimeType.includes('powerpoint') || mimeType.includes('presentation')) return 'mdi-file-powerpoint-box'
|
|
278
|
+
if (mimeType === 'text/plain') return 'mdi-file-document-outline'
|
|
279
|
+
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive')) return 'mdi-folder-zip-outline'
|
|
280
|
+
return 'mdi-file-outline'
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function getFileIconColor(mimeType: string): string {
|
|
284
|
+
if (mimeType === 'application/pdf') return 'red'
|
|
285
|
+
if (mimeType.includes('word') || mimeType.includes('document')) return 'blue'
|
|
286
|
+
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return 'green'
|
|
287
|
+
if (mimeType.includes('powerpoint') || mimeType.includes('presentation')) return 'orange'
|
|
288
|
+
if (mimeType.startsWith('video/')) return 'purple'
|
|
289
|
+
return 'grey'
|
|
290
|
+
}
|
|
291
|
+
|
|
150
292
|
function getByType(prefix: 'image/' | 'video/' | 'other') {
|
|
151
293
|
return computed(() => {
|
|
152
294
|
return rawFileArray.value.filter(x => {
|
|
@@ -116,7 +116,7 @@ const props = defineProps({
|
|
|
116
116
|
|
|
117
117
|
const startDate = ref("");
|
|
118
118
|
const endDate = ref("");
|
|
119
|
-
const status = ref<TScheduleAreaStatus>("
|
|
119
|
+
const status = ref<TScheduleAreaStatus>("all");
|
|
120
120
|
const statusOptions = [
|
|
121
121
|
{ title: "All", value: "all" },
|
|
122
122
|
{ title: "Open", value: "open" },
|
|
@@ -191,7 +191,7 @@ const {
|
|
|
191
191
|
site: props.site,
|
|
192
192
|
startDate: startDate.value,
|
|
193
193
|
endDate: endDate.value,
|
|
194
|
-
status: status.value === "
|
|
194
|
+
status: status.value === "all" ? undefined : status.value,
|
|
195
195
|
serviceType: props.serviceType,
|
|
196
196
|
}),
|
|
197
197
|
{
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="d-flex flex-column">
|
|
3
3
|
<v-text-field v-bind="$attrs" ref="dateTimePickerRef" :model-value="dateTimeFormattedReadOnly" autocomplete="off"
|
|
4
|
-
:placeholder="
|
|
4
|
+
:placeholder="inputPlaceholder" :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>
|
|
8
8
|
</v-text-field>
|
|
9
9
|
<div class="w-100 d-flex align-end ga-3 hidden-input">
|
|
10
|
-
<input ref="dateInput" type="
|
|
10
|
+
<input ref="dateInput" :type="inputType" v-model="dateTime" />
|
|
11
11
|
</div>
|
|
12
12
|
</div>
|
|
13
13
|
</template>
|
|
@@ -23,6 +23,10 @@ const prop = defineProps({
|
|
|
23
23
|
placeholder: {
|
|
24
24
|
type: String,
|
|
25
25
|
default: 'DD/MM/YYYY, HH:MM AM/PM'
|
|
26
|
+
},
|
|
27
|
+
dateOnly: {
|
|
28
|
+
type: Boolean,
|
|
29
|
+
default: false
|
|
26
30
|
}
|
|
27
31
|
})
|
|
28
32
|
|
|
@@ -31,6 +35,10 @@ const dateTime = defineModel<string | null>({ default: null }) //2025-10-10T13:0
|
|
|
31
35
|
const dateTimeUTC = defineModel<string | null>('utc', { default: null }) // UTC format
|
|
32
36
|
|
|
33
37
|
const dateTimeFormattedReadOnly = ref<string | null>(null)
|
|
38
|
+
const inputType = computed(() => (prop.dateOnly ? 'date' : 'datetime-local'))
|
|
39
|
+
const inputPlaceholder = computed(() => (
|
|
40
|
+
prop.placeholder || (prop.dateOnly ? 'MM/DD/YYYY' : 'DD/MM/YYYY, HH:MM AM/PM')
|
|
41
|
+
))
|
|
34
42
|
|
|
35
43
|
|
|
36
44
|
|
|
@@ -55,11 +63,16 @@ function convertToReadableFormat(dateStr: string): string {
|
|
|
55
63
|
if (!dateStr) return "";
|
|
56
64
|
|
|
57
65
|
const date = new Date(dateStr)
|
|
66
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
58
67
|
|
|
59
|
-
const day = String(date.getDate()).padStart(2, '0')
|
|
60
68
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
69
|
+
const day = String(date.getDate()).padStart(2, '0')
|
|
61
70
|
const year = date.getFullYear()
|
|
62
71
|
|
|
72
|
+
if (prop.dateOnly) {
|
|
73
|
+
return `${month}/${day}/${year}`
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
let hours = date.getHours()
|
|
64
77
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
|
65
78
|
|
|
@@ -72,17 +85,36 @@ function convertToReadableFormat(dateStr: string): string {
|
|
|
72
85
|
return `${day}/${month}/${year}, ${formattedTime}`
|
|
73
86
|
}
|
|
74
87
|
|
|
88
|
+
function toDateOnlyInputValue(dateStr: string): string {
|
|
89
|
+
if (!dateStr) return ''
|
|
90
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return dateStr
|
|
91
|
+
const date = new Date(dateStr)
|
|
92
|
+
if (Number.isNaN(date.getTime())) return ''
|
|
93
|
+
const year = date.getFullYear()
|
|
94
|
+
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
95
|
+
const day = String(date.getDate()).padStart(2, '0')
|
|
96
|
+
return `${year}-${month}-${day}`
|
|
97
|
+
}
|
|
98
|
+
|
|
75
99
|
function handleInitialDate(){
|
|
76
100
|
const dateDefault = dateTime.value
|
|
77
101
|
const dateUTC = dateTimeUTC.value
|
|
78
102
|
if(dateDefault){
|
|
79
103
|
dateTimeFormattedReadOnly.value = convertToReadableFormat(dateDefault)
|
|
80
|
-
|
|
81
|
-
|
|
104
|
+
if (prop.dateOnly) {
|
|
105
|
+
dateTimeUTC.value = toDateOnlyInputValue(dateDefault)
|
|
106
|
+
} else {
|
|
107
|
+
const localDate = new Date(dateDefault)
|
|
108
|
+
dateTimeUTC.value = localDate.toISOString()
|
|
109
|
+
}
|
|
82
110
|
} else if (dateUTC){
|
|
83
111
|
dateTimeFormattedReadOnly.value = convertToReadableFormat(dateUTC)
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
if (prop.dateOnly) {
|
|
113
|
+
dateTime.value = toDateOnlyInputValue(dateUTC)
|
|
114
|
+
} else {
|
|
115
|
+
const localDate = new Date(dateUTC)
|
|
116
|
+
dateTime.value = formatDateISO8601(localDate)
|
|
117
|
+
}
|
|
86
118
|
} else {
|
|
87
119
|
dateTimeFormattedReadOnly.value = null
|
|
88
120
|
}
|
|
@@ -98,8 +130,12 @@ watch(dateTime, (dateVal) => {
|
|
|
98
130
|
}
|
|
99
131
|
|
|
100
132
|
dateTimeFormattedReadOnly.value = convertToReadableFormat(dateVal)
|
|
101
|
-
|
|
102
|
-
|
|
133
|
+
if (prop.dateOnly) {
|
|
134
|
+
dateTimeUTC.value = toDateOnlyInputValue(dateVal)
|
|
135
|
+
} else {
|
|
136
|
+
const localDate = new Date(dateVal)
|
|
137
|
+
dateTimeUTC.value = localDate.toISOString()
|
|
138
|
+
}
|
|
103
139
|
|
|
104
140
|
}, { immediate: false })
|
|
105
141
|
|