@7365admin1/layer-common 1.10.2 → 1.10.3

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.10.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 1c73673: Move other Bulletin Board components
8
+
3
9
  ## 1.10.2
4
10
 
5
11
  ### Patch Changes
@@ -0,0 +1,396 @@
1
+ <template>
2
+ <v-card width="100%" :loading="loading.processing" :disabled="loading.processing || getAnnouncementByIdPending"
3
+ :key="`form-key-${formKey}`">
4
+ <v-toolbar>
5
+ <v-row no-gutters class="fill-height px-6" align="center">
6
+ <span class="font-weight-bold text-h5 text-capitalize">
7
+ {{ prop.mode }} Announcement
8
+ </span>
9
+ </v-row>
10
+ </v-toolbar>
11
+ <v-card-text style="max-height: 100vh; overflow-y: auto" class="pa-6">
12
+ <v-form ref="formRef" v-model="validForm" :disabled="loading.processing" @click="errorMessage = ''">
13
+ <v-row no-gutters>
14
+ <v-col cols="12" class="font-weight-bold text-h6 mb-2">General Information</v-col>
15
+ <v-col cols="12">
16
+ <InputLabel title="Recipient(s)" />
17
+ <v-select v-model="bulletinForm.recipients" :items="recipientList" multiple item-value="value"
18
+ placeholder="Select recipients">
19
+ <template v-slot:prepend-item>
20
+ <v-list-item title="Select All" @click="handleSelectAll" class="">
21
+ <template v-slot:prepend>
22
+ <v-checkbox-btn :color="selectedSomeRecipients ? 'indigo-darken-4' : undefined"
23
+ :indeterminate="selectedSomeRecipients && !selectedAllRecipients"
24
+ :model-value="selectedAllRecipients"></v-checkbox-btn>
25
+ </template>
26
+ </v-list-item>
27
+ </template>
28
+
29
+ <template v-slot:selection="{ item, index }">
30
+ <v-chip :text="item.title" class="py-2">
31
+ <template #append>
32
+ <v-btn icon="mdi-close" size="small" density="compact" color="transparent" flat
33
+ class="ml-3 text-black" @mousedown.stop
34
+ @click.stop="handleRemoveRecipient(item)" />
35
+ </template>
36
+ </v-chip>
37
+ </template>
38
+ </v-select>
39
+ </v-col>
40
+ <v-col cols="12">
41
+ <InputLabel class="text-capitalize" title="Title" required />
42
+ <v-text-field v-model.trim="bulletinForm.title" density="compact" :rules="[requiredRule]" />
43
+ </v-col>
44
+ <v-col cols="12" class="my-4">
45
+ <InputLabel class="text-capitalize" title="Content" required />
46
+ <Editor v-model="bulletinForm.content" />
47
+ </v-col>
48
+
49
+ <v-col cols="12">
50
+ <v-checkbox v-model="bulletinForm.noExpiration" label="No Expiration" />
51
+ </v-col>
52
+
53
+ <v-col cols="12">
54
+ <InputLabel class="text-capitalize" title="Start Date" required />
55
+ <InputDateTimePicker v-model:utc="bulletinForm.startDate" :rules="[validStartDateRule]" />
56
+ </v-col>
57
+
58
+ <v-col cols="12" v-if="!bulletinForm.noExpiration">
59
+ <InputLabel class="text-capitalize" title="End Date" required />
60
+ <InputDateTimePicker v-model:utc="bulletinForm.endDate" :rules="[validExpiryDateRule]" />
61
+ </v-col>
62
+
63
+ <v-col cols="12">
64
+ <v-col cols="12" class="font-weight-bold text-h6">Attachments</v-col>
65
+ <v-col cols="12">
66
+ <v-btn text="Upload" class="text-capitalize" color="primary-button" variant="outlined"
67
+ min-width="150" @click="handleUpload" :loading="loading.uploading" />
68
+ </v-col>
69
+ <v-row no-gutters class="px-5">
70
+ <v-col cols="12">Videos ({{ videosArray.length }})</v-col>
71
+ <AttachmentList :list="videosArray" show-checkbox @set-preview="handleSetPreview"
72
+ @remove="handleRemoveFile" />
73
+
74
+ <v-col cols="12" class="mt-5">Photos ({{ photosArray.length }})</v-col>
75
+ <AttachmentList :list="photosArray" show-checkbox @set-preview="handleSetPreview"
76
+ @remove="handleRemoveFile" />
77
+
78
+ <v-col cols="12" class="mt-5">Files ({{ filesArray.length }})</v-col>
79
+ <AttachmentList :list="filesArray" show-checkbox @set-preview="handleSetPreview"
80
+ @remove="handleRemoveFile" />
81
+ </v-row>
82
+ </v-col>
83
+ </v-row>
84
+ </v-form>
85
+ </v-card-text>
86
+
87
+ <v-row no-gutters class="w-100" v-if="errorMessage">
88
+ <p class="text-error w-100 text-center text-subtitle-2">{{ errorMessage }}</p>
89
+ </v-row>
90
+ <v-toolbar density="compact">
91
+ <v-row no-gutters>
92
+ <v-col cols="6">
93
+ <v-btn tile block variant="text" class="text-none" size="48" @click="close" text="Close" />
94
+ </v-col>
95
+ <v-col cols="6">
96
+ <v-btn tile block variant="flat" color="primary-button" class="text-none" size="48"
97
+ :text="prop.mode === 'add' ? 'Add Bulletin' : 'Save'"
98
+ :disabled="loading.processing || !validForm" @click="onSubmit" />
99
+ </v-col>
100
+ </v-row>
101
+ </v-toolbar>
102
+ </v-card>
103
+ </template>
104
+
105
+ <script setup lang="ts">
106
+ const prop = defineProps({
107
+ siteId: {
108
+ type: String,
109
+ required: true
110
+ },
111
+ mode: {
112
+ type: String as PropType<'edit' | 'add'>,
113
+ default: "add"
114
+ },
115
+ activeId: {
116
+ type: String,
117
+ default: ""
118
+ }
119
+ })
120
+
121
+ const { requiredRule } = useUtils()
122
+ const { addFile, urlToFile, getFileUrl } = useFile()
123
+ const { add, update, recipientList, getBulletinById } = useBulletin()
124
+
125
+ const bulletinForm = reactive<TCreateAnnouncementPayload>({
126
+ recipients: [],
127
+ title: "",
128
+ content: "",
129
+ site: prop.siteId,
130
+ noExpiration: false,
131
+ startDate: "",
132
+ endDate: "",
133
+ file: []
134
+ })
135
+
136
+ const emit = defineEmits(['close', 'done'])
137
+
138
+ const validForm = ref(false);
139
+ const formRef = ref<HTMLFormElement>()
140
+ const formKey = ref(0)
141
+ const errorMessage = ref('')
142
+
143
+ const rawFileArray = ref<{ file: File, _id: string, preview: boolean }[]>([])
144
+
145
+ const loading = reactive({
146
+ uploading: false,
147
+ processing: false
148
+ })
149
+
150
+
151
+
152
+
153
+ const photosArray = getByType('image/')
154
+ const videosArray = getByType('video/')
155
+ const filesArray = getByType('other')
156
+
157
+ function getByType(prefix: 'image/' | 'video/' | 'other') {
158
+ return computed(() => {
159
+ return rawFileArray.value.filter(x => {
160
+ const type = x?.file?.type
161
+ if (typeof type !== 'string') return false
162
+
163
+ if (prefix === 'other') {
164
+ return !type.startsWith('image/') && !type.startsWith('video/')
165
+ }
166
+ return type.startsWith(prefix)
167
+ })
168
+ })
169
+ }
170
+
171
+
172
+ const {
173
+ data: getAnnouncementByIdReq,
174
+ refresh: getAnnouncementByIdRefresh,
175
+ pending: getAnnouncementByIdPending,
176
+ error: getAnnouncementByIdError
177
+ } = await useLazyAsyncData(
178
+ `get-announcements-${prop.activeId}`,
179
+ () => {
180
+ if(prop.mode === 'add') return Promise.resolve(null) // ignore when add mode
181
+ const id = prop?.activeId
182
+ if (!id) {
183
+ throw new Error("Invalid activeId or mode. Cannot fetch announcement.")
184
+ }
185
+ return getBulletinById(id)
186
+ },
187
+ {
188
+ watch: [() => prop.activeId],
189
+ }
190
+ );
191
+
192
+ watch([getAnnouncementByIdReq, getAnnouncementByIdError], async ([newData, newError]) => {
193
+ if (newData) {
194
+ bulletinForm.title = newData?.title
195
+ bulletinForm.content = newData?.content
196
+ bulletinForm.noExpiration = newData?.noExpiration
197
+ bulletinForm.startDate = newData?.startDate
198
+ bulletinForm.endDate = newData?.endDate
199
+ bulletinForm.file = newData?.file
200
+ bulletinForm.recipients = newData?.recipients
201
+
202
+ // mapped rawFileArray
203
+
204
+ rawFileArray.value = await Promise.all(newData?.file?.map(async (item: TAnnouncementFile) => {
205
+ const fileUrl = getFileUrl(item?._id)
206
+ const file = await urlToFile(fileUrl, item?.name)
207
+ return {
208
+ file,
209
+ _id: item?._id,
210
+ preview: item.preview ?? false
211
+
212
+ }
213
+ }))
214
+
215
+ }
216
+
217
+ if (newError) {
218
+ const errMessage = newError?.message
219
+ console.error("Failed to load announcement:", errMessage)
220
+ errorMessage.value = errMessage || ("Cannot fetch announcement" + newError)
221
+ }
222
+ });
223
+
224
+ const allRecipientValues: TAnnouncementRecipients[] = recipientList.map(r => r.value)
225
+
226
+ const selectedAllRecipients = computed(() => {
227
+ return bulletinForm.recipients.length === allRecipientValues.length &&
228
+ bulletinForm.recipients.every((x) => allRecipientValues.includes(x))
229
+ })
230
+
231
+
232
+
233
+ const selectedSomeRecipients = computed(() => {
234
+ return bulletinForm.recipients?.length > 0
235
+ })
236
+
237
+
238
+
239
+ function handleRemoveRecipient(item: any) {
240
+ const recipientVal = item?.value
241
+ const filtered = bulletinForm.recipients.filter(x => x !== recipientVal)
242
+ bulletinForm.recipients = filtered
243
+ }
244
+
245
+ function handleSelectAll() {
246
+ if (selectedAllRecipients.value) {
247
+ bulletinForm.recipients = []
248
+ } else {
249
+ bulletinForm.recipients = allRecipientValues
250
+ }
251
+ }
252
+
253
+
254
+ function validStartDateRule(value: string) {
255
+ if (!value) {
256
+ return 'Start Date is required';
257
+ }
258
+
259
+ return true;
260
+ }
261
+
262
+ function validExpiryDateRule(value: string) {
263
+ const noExpiration = bulletinForm.noExpiration
264
+ if (noExpiration) return true;
265
+ const startDateISO = bulletinForm.startDate;
266
+ if (!value) {
267
+ return 'End Date is required';
268
+ }
269
+
270
+ if (value && startDateISO) {
271
+ const expiry = new Date(value);
272
+ const start = new Date(startDateISO as string);
273
+ return expiry >= start || 'End date must be equal or later than start date';
274
+ }
275
+ return true;
276
+ }
277
+
278
+ watch(() => bulletinForm.noExpiration, (newVal) => {
279
+ if (newVal === true) {
280
+ setTimeout(() => {
281
+ bulletinForm.endDate = ""
282
+ }, 100)
283
+ }
284
+ })
285
+
286
+
287
+ async function onSubmit() {
288
+ errorMessage.value = ""
289
+ await formRef.value?.validate()
290
+ if (!validForm.value) {
291
+ errorMessage.value = "Please complete required fields"
292
+ return;
293
+ }
294
+
295
+ let payload: Partial<TCreateAnnouncementPayload> = {}
296
+ if (prop.mode === 'add') {
297
+ payload = { ...bulletinForm }
298
+ } else if (prop.mode === 'edit') {
299
+ const { site, ...rest } = bulletinForm
300
+ payload = rest
301
+ }
302
+
303
+ try {
304
+ loading.processing = true
305
+ if (prop.mode === 'add') {
306
+ await add(payload)
307
+ } else if (prop.mode === 'edit') {
308
+ const id = prop.activeId
309
+ if (!id) throw new Error('Missing activeId prop')
310
+ await update(prop.activeId, payload)
311
+ } else {
312
+ throw new Error('Missing mode prop!')
313
+ }
314
+
315
+ emit('done')
316
+
317
+ } catch (error: any) {
318
+ const err = error?.data?.message
319
+ errorMessage.value = err || `Failed to ${prop.mode === 'add' ? 'add' : 'update'}. Please try again.`;
320
+
321
+ } finally {
322
+ loading.processing = false
323
+ }
324
+ }
325
+
326
+ function close() {
327
+ emit('close')
328
+ }
329
+
330
+ function handleUpload() {
331
+
332
+ const input = document.createElement("input")
333
+ input.type = "file"
334
+ input.multiple = true
335
+
336
+ input.onchange = async (event: Event) => {
337
+ const target = event.target as HTMLInputElement
338
+ const filesArray = target.files || []
339
+ const MAX_SIZE = 25 * 1024 * 1024 // 25MB
340
+ for (const file of filesArray) {
341
+ loading.uploading = true
342
+
343
+ if (file.size > MAX_SIZE) {
344
+ errorMessage.value = `Skipping File "${file.name}", exceeds 25MB limit.`
345
+ continue
346
+ }
347
+
348
+ try {
349
+ const res = await addFile(file)
350
+ const fileId = res?.id
351
+ const fileName = file?.name || `file-${fileId}`
352
+ if (fileId) {
353
+ rawFileArray.value.push({ file, _id: fileId, preview: false })
354
+ // bulletinForm.file.push({ name: fileName, preview: false, _id: fileId })
355
+ }
356
+ }
357
+ catch (err: any) {
358
+ console.error("Upload failed:", err)
359
+ const message = err?.data?.message
360
+ errorMessage.value = "Failed to upload image. Please try again."
361
+ } finally {
362
+ loading.uploading = false
363
+ }
364
+
365
+ }
366
+ }
367
+ input.click()
368
+ }
369
+
370
+ function handleSetPreview(fileId: string) {
371
+ rawFileArray.value.forEach(item => {
372
+ if (item._id !== fileId) {
373
+ item.preview = false
374
+ }
375
+ })
376
+ }
377
+
378
+ function handleRemoveFile(fileId: string) {
379
+ rawFileArray.value = rawFileArray.value.filter(item => item?._id !== fileId)
380
+ }
381
+
382
+ watch(rawFileArray, (newArr) => {
383
+ bulletinForm.file = newArr.map(item => {
384
+ return {
385
+ _id: item?._id,
386
+ name: item?.file?.name,
387
+ preview: item?.preview,
388
+ }
389
+ })
390
+
391
+ }, { deep: true })
392
+
393
+
394
+ </script>
395
+
396
+ <style scoped></style>
@@ -0,0 +1,195 @@
1
+ <template>
2
+ <v-card width="100%" :loading="processing">
3
+ <v-toolbar>
4
+ <v-row no-gutters class="fill-height px-6" align="center">
5
+ <span class="font-weight-bold text-subtitle-1 text-capitalize">
6
+ Preview Announcement
7
+ </span>
8
+ </v-row>
9
+ </v-toolbar>
10
+
11
+ <v-card-text style="max-height: 100vh; overflow-y: auto" class="pb-0 my-3">
12
+ <v-row no-gutters>
13
+ <template v-for="(label, key) in formattedFields" :key="key">
14
+ <v-col cols="12" class="mb-5">
15
+ <v-row no-gutters class="">
16
+ <v-col class="font-weight-bold">{{ label }}:</v-col>
17
+
18
+ <v-col cols="9" class="px-10">
19
+ <span v-if="key === 'content'" v-html="getFieldValue(key)" />
20
+ <span v-else-if="key === 'noExpiration'">
21
+ <BulletinExpirationChip :value="getFieldValue(key) as boolean" />
22
+ </span>
23
+ <span v-else>
24
+ {{ getFieldValue(key) }}
25
+ </span>
26
+ </v-col>
27
+
28
+
29
+ </v-row>
30
+ </v-col>
31
+ </template>
32
+
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>
50
+ </v-row>
51
+ </v-col>
52
+ </v-row>
53
+ </v-card-text>
54
+
55
+ <v-toolbar class="pa-0" density="compact">
56
+ <v-row no-gutters>
57
+ <v-col cols="6" class="pa-0">
58
+ <v-btn block variant="text" height="48" class="text-none" @click="emit('close')">
59
+ Close
60
+ </v-btn>
61
+ </v-col>
62
+
63
+ <v-col cols="6" class="pa-0">
64
+ <v-menu contained>
65
+ <template #activator="{ props }">
66
+ <v-btn block variant="flat" color="black" height="48" class="text-none" tile v-bind="props">
67
+ More actions
68
+ </v-btn>
69
+ </template>
70
+
71
+ <v-list class="pa-0">
72
+ <v-list-item v-if="canUpdate" @click="handleEdit">
73
+ <v-list-item-title class="text-subtitle-2">
74
+ Edit Announcement
75
+ </v-list-item-title>
76
+ </v-list-item>
77
+
78
+ <v-list-item v-if="canDelete" class="text-red" @click="handleDelete">
79
+ <v-list-item-title class="text-subtitle-2">
80
+ Delete Announcement
81
+ </v-list-item-title>
82
+ </v-list-item>
83
+ </v-list>
84
+ </v-menu>
85
+ </v-col>
86
+ </v-row>
87
+ </v-toolbar>
88
+ </v-card>
89
+ </template>
90
+
91
+
92
+ <script setup lang="ts">
93
+
94
+ const prop = defineProps({
95
+ activeAnnouncement: {
96
+ type: Object as PropType<TAnnouncement>,
97
+ required: true
98
+ },
99
+ canUpdate: Boolean,
100
+ canDelete: Boolean,
101
+ })
102
+
103
+ const processing = ref(false)
104
+ const { recipientList } = useBulletin()
105
+ const { getFileUrl, urlToFile } = useFile()
106
+
107
+ const emit = defineEmits(['close', 'edit', 'delete'])
108
+
109
+ /* Field labels */
110
+ const formattedFields: Partial<Record<keyof TAnnouncement, string>> = {
111
+ title: "Title",
112
+ createdAt: "Date Created",
113
+ recipients: "Recipients",
114
+ content: "Content",
115
+ startDate: "Start Date",
116
+ endDate: "End Date",
117
+ noExpiration: "No Expiration",
118
+ }
119
+
120
+ const formattedRecipients = computed(() => {
121
+ const arr = recipientList.filter(x =>
122
+ prop.activeAnnouncement?.recipients?.includes(x.value)
123
+ )
124
+ return arr.map(item => item.title).join(", ")
125
+ })
126
+
127
+ function getFieldValue(key: keyof TAnnouncement) {
128
+ const value = prop.activeAnnouncement?.[key]
129
+
130
+ if (value === null || value === "" || value === undefined) return ""
131
+
132
+ switch (key) {
133
+ case "recipients":
134
+ return formattedRecipients.value
135
+ case "createdAt":
136
+ case "startDate":
137
+ case "endDate":
138
+ return toLocalDate(value as string)
139
+ default:
140
+ return value
141
+ }
142
+ }
143
+
144
+ const rawFileArray = ref<{ file: File, _id: string, preview: boolean }[]>([])
145
+
146
+ const photosArray = getByType('image/')
147
+ const videosArray = getByType('video/')
148
+ const filesArray = getByType('other')
149
+
150
+ function getByType(prefix: 'image/' | 'video/' | 'other') {
151
+ return computed(() => {
152
+ return rawFileArray.value.filter(x => {
153
+ const type = x?.file?.type
154
+ if (typeof type !== 'string') return false
155
+
156
+ if (prefix === 'other') {
157
+ return !type.startsWith('image/') && !type.startsWith('video/')
158
+ }
159
+ return type.startsWith(prefix)
160
+ })
161
+ })
162
+ }
163
+
164
+ function toLocalDate(utcString: string) {
165
+ if (typeof utcString !== 'string') return;
166
+ return new Date(utcString).toLocaleString("en-US", {
167
+ year: "numeric",
168
+ month: "2-digit",
169
+ day: "2-digit",
170
+ })
171
+ }
172
+
173
+ function handleEdit() {
174
+ emit("edit")
175
+ }
176
+
177
+ function handleDelete() {
178
+ emit("delete")
179
+ }
180
+
181
+ watchEffect(async () => {
182
+ const data = prop.activeAnnouncement
183
+ if (!data) return
184
+
185
+ rawFileArray.value = await Promise.all(
186
+ data.file.map(async (item) => {
187
+ const fileUrl = getFileUrl(item._id)
188
+ const file = await urlToFile(fileUrl, item.name)
189
+ return { file, _id: item._id, preview: item.preview ?? false }
190
+ })
191
+ )
192
+ })
193
+
194
+
195
+ </script>
@@ -0,0 +1,13 @@
1
+ <template>
2
+ <v-chip
3
+ size="small"
4
+ :color="value ? 'green' : 'red'"
5
+ :text="value ? 'Yes' : 'No'"
6
+ />
7
+ </template>
8
+
9
+ <script setup lang="ts">
10
+ const props = defineProps<{
11
+ value: boolean
12
+ }>()
13
+ </script>
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "1.10.2",
5
+ "version": "1.10.3",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "publishConfig": {
@@ -0,0 +1,29 @@
1
+ declare interface TAnnouncement {
2
+ _id: string;
3
+ recipients: TAnnouncementRecipients[];
4
+ title: string;
5
+ content: string;
6
+ file: AnnouncementFile[];
7
+ createdAt: string;
8
+ updatedAt: string;
9
+ site: string;
10
+ createdBy: string;
11
+ noExpiration: boolean;
12
+ startDate: string;
13
+ endDate: string;
14
+ status: TAnnouncementStatus
15
+ }
16
+
17
+ declare interface TAnnouncementFile {
18
+ _id: string;
19
+ name: string;
20
+ preview: boolean;
21
+ }
22
+
23
+ declare type TAnnouncementRecipients = "admin" | "organization" | "site" | "service-provider" | "service-provider-member" | "resident";
24
+
25
+
26
+
27
+ declare type TCreateAnnouncementPayload = Pick<TAnnouncement, "recipients" | "file" | "site" | "noExpiration" | "startDate" | "endDate" | "content" | "title">
28
+
29
+ declare type TAnnouncementStatus = "active" | "expired"