main
vue 623 lines 17 KB
Raw
1 <template>
2 <div class="alerts-list @container">
3 <div ref="header" class="flex items-center justify-between gap-2">
4 <div class="flex items-center gap-2">
5 <div class="flex grow gap-2 @6xl:hidden!">
6 <n-popover overlap placement="left">
7 <template #trigger>
8 <div class="bg-default rounded-lg">
9 <n-button size="small" class="cursor-help!">
10 <template #icon>
11 <Icon :name="InfoIcon" />
12 </template>
13 </n-button>
14 </div>
15 </template>
16 <div class="flex flex-col gap-2">
17 <div class="box">
18 Total :
19 <code>
20 <span v-if="totalFiltered === total">{{ total }}</span>
21 <span v-else>
22 <span>{{ totalFiltered }}</span>
23 <span class="opacity-50">/</span>
24 <span class="opacity-50">{{ total }}</span>
25 </span>
26 </code>
27 </div>
28 <div class="box text-error">
29 Open :
30 <code>{{ statusOpenTotal }}</code>
31 </div>
32 <div class="box text-warning">
33 In Progress :
34 <code>{{ statusInProgressTotal }}</code>
35 </div>
36 <div class="box text-success">
37 Close :
38 <code>{{ statusCloseTotal }}</code>
39 </div>
40 </div>
41 </n-popover>
42 </div>
43 <div class="hidden grow items-center gap-1 text-sm @6xl:flex">
44 <n-button quaternary size="small" @click="filtersCTX?.setFilter([{ type: 'status', value: null }])">
45 <div class="flex items-center gap-2">
46 <span>Total</span>
47 <code class="py-1">
48 <span v-if="totalFiltered === total">{{ total }}</span>
49 <span v-else class="flex gap-1">
50 <span>{{ totalFiltered }}</span>
51 <span class="opacity-50">/</span>
52 <span class="opacity-50">{{ total }}</span>
53 </span>
54 </code>
55 </div>
56 </n-button>
57 <span>/</span>
58 <n-button
59 quaternary
60 size="small"
61 @click="filtersCTX?.setFilter([{ type: 'status', value: 'OPEN' }])"
62 >
63 <div class="flex items-center gap-2">
64 <span>Open</span>
65 <code class="text-error py-1">{{ statusOpenTotal }}</code>
66 </div>
67 </n-button>
68 <span>/</span>
69 <n-button
70 quaternary
71 size="small"
72 @click="filtersCTX?.setFilter([{ type: 'status', value: 'IN_PROGRESS' }])"
73 >
74 <div class="flex items-center gap-2">
75 <span>In Progress</span>
76 <code class="text-warning py-1">{{ statusInProgressTotal }}</code>
77 </div>
78 </n-button>
79 <span>/</span>
80 <n-button
81 quaternary
82 size="small"
83 @click="filtersCTX?.setFilter([{ type: 'status', value: 'CLOSED' }])"
84 >
85 <div class="flex items-center gap-2">
86 <span>Close</span>
87 <code class="text-success py-1">{{ statusCloseTotal }}</code>
88 </div>
89 </n-button>
90 </div>
91 <n-button size="small" text type="error" @click="showDeleteByTitleModal = true">
92 <div class="flex items-center gap-2">
93 <Icon :name="TrashIcon" />
94 <span class="hidden @3xl:inline">Bulk Delete</span>
95 </div>
96 </n-button>
97 </div>
98
99 <div class="flex items-center justify-end gap-2 whitespace-nowrap">
100 <n-pagination
101 v-model:page="currentPage"
102 v-model:page-size="pageSize"
103 :page-slot
104 :show-size-picker
105 :page-sizes
106 :item-count="totalFiltered"
107 :simple="simpleMode"
108 />
109 <n-select
110 v-model:value="sort"
111 size="small"
112 :options="sortOptions"
113 :show-checkmark="false"
114 class="max-w-20"
115 :disabled="loading"
116 />
117
118 <n-badge v-if="showFilters" :show="filtered" dot type="success" :offset="[-4, 0]">
119 <n-button size="small" secondary @click="showFiltersView = !showFiltersView">
120 <template #icon>
121 <Icon :name="FilterIcon" />
122 </template>
123 </n-button>
124 </n-badge>
125 </div>
126 </div>
127
128 <CollapseKeepAlive v-if="showFilters" :show="showFiltersView" embedded arrow="top-right">
129 <AlertsFilters
130 :use-query-string="!preset?.length"
131 :preset
132 class="p-3"
133 @submit="applyFilters"
134 @mounted="filtersCTX = $event"
135 />
136 </CollapseKeepAlive>
137
138 <n-collapse-transition
139 :show="!!checkedAlerts.length"
140 style="position: sticky; top: calc(var(--toolbar-height) - 24px); z-index: 1"
141 >
142 <n-card content-class="flex flex-wrap items-center gap-3" size="small" class="mt-3 shadow-xl" embedded>
143 <n-popover content-class="p-0!">
144 <template #trigger>
145 <n-button secondary size="small">
146 <div class="flex min-w-8 items-center gap-2">
147 <span>Selected</span>
148 <n-badge
149 :value="checkedAlerts.length"
150 color="rgba(var(--border-color-rgb) / 0.3)"
151 class="font-mono!"
152 />
153 </div>
154 </n-button>
155 </template>
156 <template #default>
157 <div class="py-4">
158 <n-scrollbar trigger="none" class="max-h-150 px-3 [&_.n-scrollbar-container]:rounded-lg">
159 <div class="flex max-w-lg flex-col gap-2">
160 <AlertItem
161 v-for="alert of checkedAlerts"
162 :key="alert.id"
163 embedded
164 compact
165 selectable
166 checked
167 :alert-data="alert"
168 @check="toggleCheck(alert)"
169 @deleted="deleted(alert)"
170 @updated="updateAlert($event)"
171 />
172 </div>
173 </n-scrollbar>
174 </div>
175 </template>
176 <template #footer>
177 <div class="flex justify-between">
178 <n-button size="small" secondary @click="setALlChecked()">Select current page</n-button>
179 <n-button size="small" secondary @click="resetChecked()">Uncheck all</n-button>
180 </div>
181 </template>
182 </n-popover>
183
184 <AlertMergeCaseButton
185 v-if="checkedNoLinkedAlerts.length"
186 :alerts="checkedNoLinkedAlerts"
187 size="small"
188 @updated="updateAlert"
189 @merged="resetChecked()"
190 />
191
192 <n-popconfirm to="body" @positive-click="deleteAlerts()">
193 <template #trigger>
194 <n-button size="small" type="error" secondary :loading="deleting">
195 <template #icon>
196 <Icon :name="TrashIcon" />
197 </template>
198 Delete
199 </n-button>
200 </template>
201 Are you sure you want to delete selected Alerts?
202 </n-popconfirm>
203 </n-card>
204 </n-collapse-transition>
205
206 <n-modal
207 v-model:show="showDeleteByTitleModal"
208 preset="dialog"
209 title="Delete Alerts by Title"
210 :positive-text="deletingByTitle ? 'Deleting...' : 'Delete'"
211 negative-text="Cancel"
212 :positive-button-props="{ disabled: !titleFilterInput || deletingByTitle }"
213 @positive-click="deleteAlertsByTitle"
214 >
215 <div class="flex flex-col gap-4">
216 <p class="text-sm opacity-70">
217 Delete all alerts matching a title filter. This will perform a partial, case-insensitive match.
218 </p>
219 <n-input
220 v-model:value="titleFilterInput"
221 placeholder="e.g., 'File' to match 'File added to the system.'"
222 :disabled="deletingByTitle"
223 @keyup.enter="titleFilterInput && deleteAlertsByTitle()"
224 >
225 <template #prefix>
226 <Icon :name="FilterIcon" />
227 </template>
228 </n-input>
229 <n-alert v-if="titleFilterInput" type="warning" :bordered="false">
230 This will delete all alerts with titles containing "{{ titleFilterInput }}".
231 <br />
232 <strong>This action cannot be undone.</strong>
233 </n-alert>
234 </div>
235 </n-modal>
236
237 <n-spin :show="loading">
238 <div class="my-3 flex min-h-52 flex-col gap-2">
239 <template v-if="alertsList.length">
240 <AlertItem
241 v-for="alert of alertsList"
242 :key="alert.id"
243 selectable
244 :checked="isChecked(alert)"
245 :alert-data="alert"
246 :highlight="highlight === alert.id.toString()"
247 :details-on-mounted="highlight === alert.id.toString() && !highlightedItemOpened"
248 class="item-appear item-appear-bottom item-appear-005"
249 @check="toggleCheck(alert)"
250 @opened="highlightedItemOpened = true"
251 @deleted="deleted(alert)"
252 @updated="updateAlert($event)"
253 />
254 </template>
255 <template v-else>
256 <n-empty v-if="!loading" description="No items found" class="h-48 justify-center" />
257 </template>
258 </div>
259 </n-spin>
260 <div class="footer flex justify-end">
261 <n-pagination
262 v-if="alertsList.length > 3"
263 v-model:page="currentPage"
264 :page-size
265 :item-count="totalFiltered"
266 :page-slot="6"
267 />
268 </div>
269 </div>
270 </template>
271
272 <script setup lang="ts">
273 // TODO-FE: refactor
274 import type { AlertsListFilter } from "./types.d"
275 import type { AlertsQuery } from "@/api/endpoints/incidentManagement/alerts"
276 import type { Alert } from "@/types/incidentManagement/alerts.d"
277 import type { Case } from "@/types/incidentManagement/cases.d"
278 import { useResizeObserver, useStorage } from "@vueuse/core"
279 import axios from "axios"
280 import _orderBy from "lodash/orderBy"
281 import {
282 NAlert,
283 NBadge,
284 NButton,
285 NCard,
286 NCollapseTransition,
287 NEmpty,
288 NInput,
289 NModal,
290 NPagination,
291 NPopconfirm,
292 NPopover,
293 NScrollbar,
294 NSelect,
295 NSpin,
296 useMessage
297 } from "naive-ui"
298 import { computed, defineAsyncComponent, nextTick, onBeforeMount, provide, ref, watch } from "vue"
299 import Api from "@/api"
300 import CollapseKeepAlive from "@/components/common/CollapseKeepAlive.vue"
301 import Icon from "@/components/common/Icon.vue"
302 import AlertItem from "./AlertItem.vue"
303 import AlertsFilters from "./AlertsFilters.vue"
304
305 const {
306 highlight,
307 preset,
308 showFilters = true
309 } = defineProps<{
310 highlight?: string | null
311 preset?: AlertsListFilter[]
312 showFilters?: boolean
313 }>()
314
315 const AlertMergeCaseButton = defineAsyncComponent(() => import("./AlertMergeCaseButton.vue"))
316
317 const FilterIcon = "carbon:filter-edit"
318 const TrashIcon = "carbon:trash-can"
319 const InfoIcon = "carbon:information"
320
321 const checkedAlerts = ref<Alert[]>([])
322 const checkedNoLinkedAlerts = computed(() => checkedAlerts.value.filter(alert => !alert.linked_cases.length))
323 const message = useMessage()
324 const loading = ref(false)
325 const deleting = ref(false)
326 const deletingByTitle = ref(false)
327 const showDeleteByTitleModal = ref(false)
328 const titleFilterInput = ref("")
329 const showFiltersView = useStorage<boolean>("incident-management-alerts-list-filters-view-state", false, localStorage)
330 const alertsList = ref<Alert[]>([])
331 const availableUsers = ref<string[]>([])
332 const linkableCases = ref<Case[]>([])
333 let abortController: AbortController | null = null
334
335 const currentPage = ref(1)
336 const simpleMode = ref(false)
337 const showSizePicker = ref(true)
338 const pageSizes = [10, 25, 50, 100]
339 const pageSize = ref(pageSizes[1])
340 const header = ref()
341 const pageSlot = ref(8)
342 const sort = defineModel<"asc" | "desc">("sort", { default: "desc" })
343 const sortOptions = [
344 { label: "Desc", value: "desc" },
345 { label: "Asc", value: "asc" }
346 ]
347
348 const totalFiltered = ref(0)
349 const total = ref(0)
350 const statusOpenTotal = ref(0)
351 const statusInProgressTotal = ref(0)
352 const statusCloseTotal = ref(0)
353
354 const filtersCTX = ref<{ setFilter: (payload: AlertsListFilter[]) => void } | null>(null)
355 const filters = ref<AlertsListFilter[]>([])
356
357 const filtered = computed<boolean>(() => {
358 return !!filters.value.length
359 })
360
361 const highlightedItemFound = ref(!highlight)
362 const highlightedItemOpened = ref(!highlight)
363
364 watch([currentPage, sort], () => {
365 getData()
366 })
367
368 watch(pageSize, () => {
369 if (currentPage.value === 1) {
370 getData()
371 } else {
372 currentPage.value = 1
373 }
374 })
375
376 watch(
377 alertsList,
378 () => {
379 if (
380 alertsList.value.length &&
381 !alertsList.value.some(o => o.id.toString() === highlight) &&
382 currentPage.value < totalFiltered.value &&
383 !highlightedItemFound.value
384 ) {
385 nextTick(() => {
386 currentPage.value++
387 })
388 }
389
390 if (alertsList.value.some(o => o.id.toString() === highlight)) {
391 highlightedItemFound.value = true
392 }
393 },
394 { immediate: true }
395 )
396
397 provide("assignable-users", availableUsers)
398
399 provide("linkable-cases", linkableCases)
400
401 function applyFilters(newFilters: AlertsListFilter[]) {
402 filters.value = newFilters
403 getData()
404 }
405
406 function updateAlert(updatedAlert: Alert) {
407 const alertIndex = alertsList.value.findIndex(o => o.id === updatedAlert.id)
408 if (alertIndex !== -1) {
409 alertsList.value[alertIndex] = updatedAlert
410 }
411 }
412
413 function isChecked(alert: Alert) {
414 return checkedAlerts.value.some(o => o.id === alert.id)
415 }
416
417 function toggleCheck(alert: Alert) {
418 const alertIndex = checkedAlerts.value.findIndex(o => o.id === alert.id)
419
420 if (checkedAlerts.value.some(o => o.id === alert.id)) {
421 checkedAlerts.value.splice(alertIndex, 1)
422 } else {
423 checkedAlerts.value.push(alert)
424 }
425 }
426
427 function resetChecked() {
428 checkedAlerts.value = []
429 }
430
431 function setALlChecked() {
432 for (const alert of alertsList.value) {
433 if (!isChecked(alert)) {
434 toggleCheck(alert)
435 }
436 }
437 }
438
439 function removeChecked(alert: Alert) {
440 const alertIndex = checkedAlerts.value.findIndex(o => o.id === alert.id)
441
442 if (checkedAlerts.value.some(o => o.id === alert.id)) {
443 checkedAlerts.value.splice(alertIndex, 1)
444 }
445 }
446
447 function deleted(alert: Alert) {
448 removeChecked(alert)
449 getData()
450 }
451
452 function getData() {
453 abortController?.abort()
454 abortController = new AbortController()
455
456 loading.value = true
457
458 const query: Partial<AlertsQuery> = {
459 page: currentPage.value,
460 pageSize: pageSize.value,
461 sort: sort.value
462 }
463
464 if (filtered.value) {
465 query.filters = filters.value
466 }
467
468 Api.incidentManagement.alerts
469 .getAlertsList(query, abortController.signal)
470 .then(res => {
471 if (res.data.success) {
472 alertsList.value = res.data?.alerts || []
473 total.value = res.data.total || 0
474 totalFiltered.value = res.data.total_filtered ?? total.value ?? 0
475 statusCloseTotal.value = res.data.closed || 0
476 statusInProgressTotal.value = res.data.in_progress || 0
477 statusOpenTotal.value = res.data.open || 0
478 } else {
479 message.warning(res.data?.message || "An error occurred. Please try again later.")
480 }
481 loading.value = false
482 })
483 .catch(err => {
484 if (!axios.isCancel(err)) {
485 alertsList.value = []
486
487 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
488 loading.value = false
489 }
490 })
491 }
492
493 function getAvailableUsers() {
494 Api.incidentManagement.alerts
495 .getAvailableUsers()
496 .then(res => {
497 if (res.data.success) {
498 availableUsers.value = res.data?.available_users || []
499 } else {
500 message.warning(res.data?.message || "An error occurred. Please try again later.")
501 }
502 })
503 .catch(err => {
504 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
505 })
506 }
507
508 function getCases() {
509 Api.incidentManagement.cases
510 .getCasesList(undefined, { page: 1, pageSize: 9999 })
511 .then(res => {
512 if (res.data.success) {
513 linkableCases.value = _orderBy(res.data?.cases || [], ["id"], ["desc"])
514 } else {
515 message.warning(res.data?.message || "An error occurred. Please try again later.")
516 }
517 })
518 .catch(err => {
519 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
520 })
521 }
522
523 function deleteAlerts() {
524 deleting.value = true
525
526 Api.incidentManagement.alerts
527 .deleteAlerts(checkedAlerts.value.map(o => o.id))
528 .then(res => {
529 if (res.data.success) {
530 if (res.data.deleted_alert_ids.length) {
531 for (const id of res.data.deleted_alert_ids) {
532 toggleCheck({ id } as Alert)
533 }
534
535 if (res.data.not_deleted_alert_ids.length) {
536 message.warning("Some alerts could not be deleted.")
537 } else {
538 message.success(res.data?.message || "Alerts deleted successfully.")
539 }
540 } else {
541 message.warning("The selected alerts could not be deleted")
542 }
543
544 getData()
545 } else {
546 message.warning(res.data?.message || "An error occurred. Please try again later.")
547 }
548 })
549 .catch(err => {
550 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
551 })
552 .finally(() => {
553 deleting.value = false
554 })
555 }
556
557 function deleteAlertsByTitle() {
558 if (!titleFilterInput.value) return
559
560 deletingByTitle.value = true
561
562 Api.incidentManagement.alerts
563 .deleteAlertsByTitle(titleFilterInput.value)
564 .then(res => {
565 if (res.data.success) {
566 const deletedCount = res.data.deleted_alert_ids.length
567 const skippedCount = res.data.not_deleted_alert_ids.length
568
569 if (deletedCount > 0) {
570 if (skippedCount > 0) {
571 message.warning(
572 `Deleted ${deletedCount} alert(s). ${skippedCount} alert(s) were skipped (linked to cases).`
573 )
574 } else {
575 message.success(`Successfully deleted ${deletedCount} alert(s).`)
576 }
577
578 // Clear any checked alerts that were deleted
579 checkedAlerts.value = checkedAlerts.value.filter(
580 alert => !res.data.deleted_alert_ids.includes(alert.id)
581 )
582
583 // Refresh the list
584 getData()
585 } else {
586 message.info("No alerts were deleted. They may be linked to cases or not found.")
587 }
588
589 // Close modal and reset input
590 showDeleteByTitleModal.value = false
591 titleFilterInput.value = ""
592 } else {
593 message.warning(res.data?.message || "An error occurred. Please try again later.")
594 }
595 })
596 .catch(err => {
597 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
598 })
599 .finally(() => {
600 deletingByTitle.value = false
601 })
602 }
603
604 useResizeObserver(header, entries => {
605 const entry = entries[0]
606 if (!entry) return
607
608 const { width } = entry.contentRect
609
610 pageSlot.value = width < 700 ? 5 : 8
611 simpleMode.value = width < 550
612 })
613
614 onBeforeMount(() => {
615 if (!showFilters && preset?.length) {
616 filters.value = preset
617 }
618
619 getData()
620 getAvailableUsers()
621 getCases()
622 })
623 </script>