@7365admin1/layer-common 1.11.20 → 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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 1.11.21
4
+
5
+ ### Patch Changes
6
+
7
+ - 17a59f6: Update Layer-common
8
+
3
9
  ## 1.11.20
4
10
 
5
11
  ### 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 & Keys</span>
5
+ <span class="font-weight-bold text-subtitle-1">{{ isEditMode ? 'Edit' : 'Assign' }} Pass &amp;
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
- Name: {{ prop.visitor.name }}
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
- <v-autocomplete
21
- v-model="selectedPass"
22
- v-model:search="passInput"
23
- :hide-no-data="false"
24
- :items="passItems"
25
- item-title="prefixAndName"
26
- item-value="_id"
27
- variant="outlined"
28
- hide-details
29
- density="compact"
30
- small-chips
31
- :loading="fetchPassesPending"
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
- <v-autocomplete
48
- v-model="selectedKeys"
49
- v-model:search="keyInput"
50
- :hide-no-data="false"
51
- :items="keyItems"
52
- item-title="prefixAndName"
53
- item-value="_id"
54
- multiple
55
- variant="outlined"
56
- hide-details
57
- density="compact"
58
- small-chips
59
- :loading="fetchKeysPending"
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
- tile block variant="flat" color="black" class="text-none" size="48"
87
- text="Assign"
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
- type: {
113
- type: String as PropType<TVisitorType>,
114
- required: true,
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
- if (prop.type === 'contractor') {
144
- return prop.contractorType === 'property-agent' ? ['agent-pass'] : ['contractor-pass']
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
- const { data: passesData, pending: fetchPassesPending } = await useLazyAsyncData(
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
- passItems.value = Array.isArray(data?.items) ? data.items : []
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
- keyItems.value = Array.isArray(data?.items) ? data.items : []
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
- payload.visitorPass = [{ keyId: selectedPass.value, status: "In Use" }]
243
+ finalPasses.push({ keyId: selectedPass.value, status: 'In Use' })
191
244
  }
245
+ payload.visitorPass = finalPasses
192
246
 
193
- if (selectedKeys.value.length > 0) {
194
- payload.passKeys = selectedKeys.value.map(keyId => ({ keyId, status: "In Use" }))
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 assign pass & keys. Please try again.'
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
+
@@ -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 class="font-weight-bold text-subtitle-1">Attachments:</v-row>
35
- <v-row no-gutters class="">
36
- <template v-if="videosArray.length > 0">
37
- <v-col cols="12">Videos ({{ videosArray.length }})</v-col>
38
- <AttachmentList :list="videosArray" show-checkbox read-only />
39
- </template>
40
-
41
- <template v-if="photosArray.length > 0">
42
- <v-col cols="12" class="mt-1">Photos ({{ photosArray.length }})</v-col>
43
- <AttachmentList :list="photosArray" show-checkbox read-only />
44
- </template>
45
-
46
- <template v-if="filesArray.length > 0">
47
- <v-col cols="12" class="mt-1">Files ({{ filesArray.length }})</v-col>
48
- <AttachmentList :list="filesArray" show-checkbox read-only />
49
- </template>
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 => {