main
vue 435 lines 11.4 KB
Raw
1 <template>
2 <div class="cases-list">
3 <div ref="header" class="header flex items-center justify-end gap-2">
4 <div class="info flex grow gap-2">
5 <n-popover overlap placement="left" to="body">
6 <template #trigger>
7 <div class="bg-default rounded-lg">
8 <n-button size="small" class="cursor-help!">
9 <template #icon>
10 <Icon :name="InfoIcon" />
11 </template>
12 </n-button>
13 </div>
14 </template>
15 <div class="flex flex-col gap-2">
16 <div class="box">
17 Total :
18 <code>{{ total }}</code>
19 </div>
20 <div class="box text-error">
21 Open :
22 <code>{{ statusOpenTotal }}</code>
23 </div>
24 <div class="box text-warning">
25 In Progress :
26 <code>{{ statusInProgressTotal }}</code>
27 </div>
28 <div class="box text-success">
29 Close :
30 <code>{{ statusCloseTotal }}</code>
31 </div>
32 </div>
33 </n-popover>
34
35 <n-popover v-if="showMobileMenu" overlap placement="left" display-directive="show">
36 <template #trigger>
37 <div class="bg-default rounded-lg">
38 <n-button size="small">
39 <template #icon>
40 <Icon :name="MenuIcon" />
41 </template>
42 </n-button>
43 </div>
44 </template>
45 <div class="flex flex-col gap-2 py-1">
46 <CasesExport show-icon size="small" />
47 <CaseCreationButton show-icon size="small" @submitted="getData()" />
48 </div>
49 </n-popover>
50
51 <CasesExport v-if="!showMobileMenu" :show-icon="caseExportButtonShowIcon" size="small" />
52
53 <CaseCreationButton
54 v-if="!showMobileMenu"
55 :show-icon="caseCreationButtonShowIcon"
56 size="small"
57 @submitted="getData()"
58 />
59 </div>
60 <n-pagination
61 v-model:page="currentPage"
62 v-model:page-size="pageSize"
63 :page-slot
64 :show-size-picker
65 :page-sizes
66 :item-count="total"
67 :simple="simpleMode"
68 />
69 <n-select
70 v-model:value="sort"
71 size="small"
72 :options="sortOptions"
73 :show-checkmark="false"
74 class="max-w-20"
75 :disabled="loading"
76 />
77 <n-popover v-if="!hideFilters" :show="showFilters" trigger="manual" overlap placement="right" class="px-0!">
78 <template #trigger>
79 <div class="bg-default rounded-lg">
80 <n-badge :show="filtered" dot type="success" :offset="[-4, 0]">
81 <n-button size="small" @click="showFilters = true">
82 <template #icon>
83 <Icon :name="FilterIcon" />
84 </template>
85 </n-button>
86 </n-badge>
87 </div>
88 </template>
89 <div class="flex flex-col gap-2 py-1">
90 <div class="px-3">
91 <n-input-group>
92 <n-select
93 v-model:value="filters.type"
94 :options="typeOptions"
95 placeholder="Filter by..."
96 clearable
97 class="w-40!"
98 />
99
100 <n-select
101 v-if="filters.type === 'status'"
102 v-model:value="filters.value"
103 :options="statusOptions"
104 placeholder="Value..."
105 :disabled="!filters.type"
106 clearable
107 class="w-56!"
108 />
109 <n-select
110 v-else-if="filters.type === 'assignedTo'"
111 v-model:value="filters.value"
112 :options="usersOptions"
113 placeholder="Value..."
114 :disabled="!filters.type"
115 clearable
116 filterable
117 class="w-56!"
118 />
119 <n-select
120 v-else-if="filters.type === 'customerCode'"
121 v-model:value="filters.value"
122 :options="customersOptions"
123 placeholder="Value..."
124 :disabled="!filters.type"
125 clearable
126 filterable
127 class="w-56!"
128 />
129 <n-input
130 v-else
131 v-model:value="filters.value"
132 placeholder="Value..."
133 :disabled="!filters.type"
134 clearable
135 class="w-56!"
136 />
137 </n-input-group>
138 </div>
139 <div class="flex justify-between gap-2 px-3">
140 <div class="flex justify-start gap-2">
141 <n-button size="small" quaternary @click="showFilters = false">Close</n-button>
142 </div>
143 <div class="flex justify-end gap-2">
144 <n-button size="small" secondary @click="resetFilters()">Reset</n-button>
145 <n-button size="small" type="primary" secondary :loading @click="getData()">
146 Submit
147 </n-button>
148 </div>
149 </div>
150 </div>
151 </n-popover>
152 </div>
153 <n-spin :show="loading">
154 <div class="my-3 flex min-h-52 flex-col gap-2">
155 <template v-if="casesList.length">
156 <CaseItem
157 v-for="item of casesList"
158 :key="item.id"
159 :case-data="item"
160 :highlight="highlight === item.id.toString()"
161 :details-on-mounted="highlight === item.id.toString() && !highlightedItemOpened"
162 class="item-appear item-appear-bottom item-appear-005"
163 @opened="highlightedItemOpened = true"
164 @deleted="getData()"
165 @updated="updateCase($event)"
166 />
167 </template>
168 <template v-else>
169 <n-empty v-if="!loading" description="No items found" class="h-48 justify-center" />
170 </template>
171 </div>
172 </n-spin>
173 <div class="footer flex justify-end">
174 <n-pagination
175 v-if="casesList.length > 3"
176 v-model:page="currentPage"
177 :page-size
178 :item-count="total"
179 :page-slot="6"
180 />
181 </div>
182 </div>
183 </template>
184
185 <script setup lang="ts">
186 // TODO-FE: refactor
187 import type { CasesFilter, CasesFilterTypes } from "@/api/endpoints/incidentManagement/cases"
188 import type { Customer } from "@/types/customers.d"
189 import type { Case, CaseStatus } from "@/types/incidentManagement/cases.d"
190 import { useResizeObserver } from "@vueuse/core"
191 import _cloneDeep from "lodash/cloneDeep"
192 import {
193 NBadge,
194 NButton,
195 NEmpty,
196 NInput,
197 NInputGroup,
198 NPagination,
199 NPopover,
200 NSelect,
201 NSpin,
202 useMessage
203 } from "naive-ui"
204 import { computed, nextTick, onBeforeMount, provide, ref, toRefs, watch } from "vue"
205 import Api from "@/api"
206 import Icon from "@/components/common/Icon.vue"
207 import CaseCreationButton from "./CaseCreationButton.vue"
208 import CaseItem from "./CaseItem.vue"
209 import CasesExport from "./CasesExport.vue"
210
211 export interface CasesListFilter {
212 type: CasesFilterTypes
213 value: string | CaseStatus
214 }
215
216 const props = defineProps<{ highlight?: string | null; preset?: CasesListFilter; hideFilters?: boolean }>()
217 const { highlight, preset, hideFilters } = toRefs(props)
218
219 const message = useMessage()
220 const loading = ref(false)
221 const showFilters = ref(false)
222 const casesList = ref<Case[]>([])
223 const availableUsers = ref<string[]>([])
224 const customersList = ref<Customer[]>([])
225
226 const currentPage = ref(1)
227 const simpleMode = ref(false)
228 const caseCreationButtonShowIcon = ref(false)
229 const caseExportButtonShowIcon = ref(false)
230 const showMobileMenu = ref(false)
231 const showSizePicker = ref(true)
232 const pageSizes = [10, 25, 50, 100]
233 const pageSize = ref(pageSizes[1])
234 const header = ref()
235 const pageSlot = ref(8)
236 const sort = ref<"asc" | "desc">("desc")
237 const sortOptions = [
238 { label: "Desc", value: "desc" },
239 { label: "Asc", value: "asc" }
240 ]
241
242 const total = ref(0)
243 const statusOpenTotal = ref(0)
244 const statusInProgressTotal = ref(0)
245 const statusCloseTotal = ref(0)
246
247 const FilterIcon = "carbon:filter-edit"
248 const MenuIcon = "carbon:overflow-menu-horizontal"
249 const InfoIcon = "carbon:information"
250
251 const filters = ref<Partial<CasesListFilter>>({})
252 const lastFilters = ref<Partial<CasesListFilter>>({})
253
254 const filtered = computed<boolean>(() => {
255 return !!filters.value.type && !!filters.value.value
256 })
257
258 const typeOptions: { label: string; value: CasesFilterTypes }[] = [
259 { label: "Status", value: "status" },
260 { label: "Assigned To", value: "assignedTo" },
261 { label: "Hostname", value: "hostname" },
262 { label: "Customer Code", value: "customerCode" }
263 ]
264
265 const statusOptions: { label: string; value: CaseStatus }[] = [
266 { label: "Open", value: "OPEN" },
267 { label: "Closed", value: "CLOSED" },
268 { label: "In progress", value: "IN_PROGRESS" }
269 ]
270
271 const usersOptions = computed(() => availableUsers.value.map(o => ({ label: o, value: o })))
272 const customersOptions = computed(() =>
273 customersList.value.map(o => ({ label: `#${o.customer_code} - ${o.customer_name}`, value: o.customer_code }))
274 )
275
276 const highlightedItemFound = ref(!highlight.value)
277 const highlightedItemOpened = ref(!highlight.value)
278
279 watch([currentPage, sort], () => {
280 getData()
281 })
282
283 watch(pageSize, () => {
284 if (currentPage.value === 1) {
285 getData()
286 } else {
287 currentPage.value = 1
288 }
289 })
290
291 watch(showFilters, val => {
292 if (!val) {
293 filters.value = _cloneDeep(lastFilters.value)
294 }
295 })
296
297 watch(
298 () => filters.value.type,
299 () => {
300 if (!preset.value) {
301 filters.value.value = undefined
302 }
303 }
304 )
305
306 watch(
307 casesList,
308 () => {
309 if (
310 casesList.value.length &&
311 !casesList.value.some(o => o.id.toString() === highlight.value) &&
312 currentPage.value < Math.ceil(total.value / pageSize.value) &&
313 !highlightedItemFound.value
314 ) {
315 nextTick(() => {
316 currentPage.value++
317 })
318 }
319
320 if (casesList.value.some(o => o.id.toString() === highlight.value)) {
321 highlightedItemFound.value = true
322 }
323 },
324 { immediate: true }
325 )
326
327 provide("assignable-users", availableUsers)
328 provide("customers-list", customersList)
329
330 function resetFilters() {
331 filters.value.type = undefined
332 showFilters.value = false
333 getData()
334 }
335
336 function updateCase(updatedCase: Case) {
337 const caseIndex = casesList.value.findIndex(o => o.id === updatedCase.id)
338 if (caseIndex !== -1) {
339 casesList.value[caseIndex] = updatedCase
340 }
341 }
342
343 function getData() {
344 showFilters.value = false
345 loading.value = true
346
347 lastFilters.value = _cloneDeep(filters.value)
348
349 let query: CasesFilter | undefined
350 if (filtered.value) {
351 // @ts-expect-error filters properties infer are ignored
352 query = { [filters.value.type]: filters.value.value }
353 }
354
355 Api.incidentManagement.cases
356 .getCasesList(query, {
357 page: currentPage.value,
358 pageSize: pageSize.value,
359 order: sort.value
360 })
361 .then(res => {
362 if (res.data.success) {
363 casesList.value = res.data?.cases || []
364 total.value = res.data.total || 0
365 statusOpenTotal.value = res.data.open || 0
366 statusInProgressTotal.value = res.data.in_progress || 0
367 statusCloseTotal.value = res.data.closed || 0
368 } else {
369 message.warning(res.data?.message || "An error occurred. Please try again later.")
370 }
371 })
372 .catch(err => {
373 casesList.value = []
374
375 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
376 })
377 .finally(() => {
378 loading.value = false
379 })
380 }
381
382 function getAvailableUsers() {
383 Api.incidentManagement.alerts
384 .getAvailableUsers()
385 .then(res => {
386 if (res.data.success) {
387 availableUsers.value = res.data?.available_users || []
388 } else {
389 message.warning(res.data?.message || "An error occurred. Please try again later.")
390 }
391 })
392 .catch(err => {
393 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
394 })
395 }
396
397 function getCustomers() {
398 Api.customers
399 .getCustomers()
400 .then(res => {
401 if (res.data.success) {
402 customersList.value = res.data?.customers || []
403 } else {
404 message.warning(res.data?.message || "An error occurred. Please try again later.")
405 }
406 })
407 .catch(err => {
408 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
409 })
410 }
411
412 useResizeObserver(header, entries => {
413 const entry = entries[0]
414 if (!entry) return
415
416 const { width } = entry.contentRect
417
418 pageSlot.value = width < 700 ? 5 : 8
419 simpleMode.value = width < 550
420 caseCreationButtonShowIcon.value = width >= 580
421 caseExportButtonShowIcon.value = width >= 580
422 showMobileMenu.value = width <= 420
423 })
424
425 onBeforeMount(() => {
426 if (preset.value?.type && preset.value.value) {
427 filters.value.type = preset.value.type
428 filters.value.value = preset.value.value
429 }
430
431 getData()
432 getAvailableUsers()
433 getCustomers()
434 })
435 </script>