main
vue 197 lines 6.25 KB
Raw
1 <template>
2 <div class="case-timeline flex flex-col gap-6">
3 <div class="flex items-center justify-between gap-3">
4 <Badge type="splitted">
5 <template #label>Events</template>
6 <template #value>
7 {{ events.length }}
8 </template>
9 </Badge>
10
11 <n-button size="small" secondary @click="fetchTimeline">
12 <template #icon><Icon name="carbon:renew" /></template>
13 Refresh
14 </n-button>
15 </div>
16
17 <n-spin :show="loading">
18 <n-timeline v-if="events.length">
19 <n-timeline-item
20 v-for="event in events"
21 :key="event.id"
22 :type="timelineType(event)"
23 :time="formatDate(event.timestamp, dFormats.datetimesec).toString()"
24 >
25 <template #header>
26 <div class="flex flex-wrap items-center gap-2">
27 <Icon :name="iconFor(event)" :size="16" class="text-secondary" />
28 <span class="font-medium">{{ summary(event) }}</span>
29 <n-tag size="tiny" :bordered="false">{{ event.actor }}</n-tag>
30 </div>
31 </template>
32 <div v-if="hasDetail(event)" class="text-secondary mt-1 text-sm">
33 <component :is="renderDetail(event)" />
34 </div>
35 </n-timeline-item>
36 </n-timeline>
37 <n-empty v-else-if="!loading" description="No timeline events yet" class="h-32 justify-center" />
38 </n-spin>
39 </div>
40 </template>
41
42 <script setup lang="ts">
43 import type { CaseEvent } from "@/types/incidentManagement/caseTemplates.d"
44 import { NButton, NEmpty, NSpin, NTag, NTimeline, NTimelineItem, useMessage } from "naive-ui"
45 import { h, onBeforeMount, ref } from "vue"
46 import Api from "@/api"
47 import Badge from "@/components/common/Badge.vue"
48 import Icon from "@/components/common/Icon.vue"
49 import { useSettingsStore } from "@/stores/settings"
50 import { formatDate } from "@/utils/format"
51
52 const props = defineProps<{
53 caseId: number
54 }>()
55
56 const dFormats = useSettingsStore().dateFormat
57
58 const message = useMessage()
59 const events = ref<CaseEvent[]>([])
60 const loading = ref(false)
61
62 function fetchTimeline() {
63 loading.value = true
64 Api.incidentManagement.caseTemplates
65 .getCaseTimeline(props.caseId)
66 .then(res => {
67 if (res.data.success) {
68 events.value = res.data.events
69 } else {
70 message.warning(res.data.message)
71 }
72 })
73 .catch(err => {
74 message.error(err.response?.data?.message || "Failed to load case timeline")
75 })
76 .finally(() => {
77 loading.value = false
78 })
79 }
80
81 // Per-event-type rendering. Falls back gracefully on unknown event types
82 // so a future backend addition doesn't blank-render the timeline.
83 function summary(event: CaseEvent): string {
84 const p = (event.payload || {}) as Record<string, any>
85 switch (event.event_type) {
86 case "case_created":
87 return p.source === "from_alert" ? `Case created from alert #${p.alert_id}` : "Case created"
88 case "case_status_changed":
89 return p.forced
90 ? `Status forced from ${p.from ?? ""} to ${p.to} (mandatory tasks bypassed)`
91 : `Status changed from ${p.from ?? ""} to ${p.to}`
92 case "case_assigned":
93 return p.from
94 ? `Reassigned from ${p.from} to ${p.to ?? "unassigned"}`
95 : `Assigned to ${p.to ?? "unassigned"}`
96 case "case_escalated":
97 return p.escalated ? "Case escalated" : "Case de-escalated"
98 case "alert_linked":
99 return p.alert_ids ? `${p.alert_ids.length} alert(s) linked to case` : `Alert #${p.alert_id} linked`
100 case "alert_unlinked":
101 return `Alert #${p.alert_id} unlinked`
102 case "comment_added":
103 return "Comment added"
104 case "template_applied":
105 return `Template applied: ${p.template_name ?? `#${p.template_id}`} (${p.tasks_added ?? 0} task${p.tasks_added === 1 ? "" : "s"})`
106 case "task_added":
107 return `Task added: ${p.title ?? `#${p.task_id}`}${p.mandatory ? " (mandatory)" : ""}`
108 case "task_status_changed":
109 return `Task ${p.title ?? `#${p.task_id}`}: ${p.from_status ?? ""}${p.to_status ?? ""}`
110 case "task_commented":
111 return `Evidence added on task: ${p.title ?? `#${p.task_id}`}`
112 default:
113 return String(event.event_type).replace(/_/g, " ")
114 }
115 }
116
117 function timelineType(event: CaseEvent): "default" | "success" | "info" | "warning" | "error" {
118 const p = (event.payload || {}) as Record<string, any>
119 switch (event.event_type) {
120 case "case_created":
121 case "alert_linked":
122 case "template_applied":
123 return "info"
124 case "case_status_changed":
125 return p.to === "CLOSED" ? "success" : p.to === "OPEN" ? "info" : "warning"
126 case "task_status_changed":
127 return p.to_status === "DONE" ? "success" : p.to_status === "NOT_NECESSARY" ? "warning" : "default"
128 case "case_escalated":
129 return p.escalated ? "warning" : "default"
130 case "alert_unlinked":
131 return "warning"
132 case "comment_added":
133 case "task_added":
134 case "task_commented":
135 case "case_assigned":
136 default:
137 return "default"
138 }
139 }
140
141 function iconFor(event: CaseEvent): string {
142 switch (event.event_type) {
143 case "case_created":
144 return "carbon:document-add"
145 case "case_status_changed":
146 return "carbon:flow-modeler"
147 case "case_assigned":
148 return "carbon:user-avatar-filled-alt"
149 case "case_escalated":
150 return "carbon:warning-alt"
151 case "alert_linked":
152 return "carbon:link"
153 case "alert_unlinked":
154 return "carbon:unlink"
155 case "comment_added":
156 return "carbon:chat"
157 case "template_applied":
158 return "carbon:flow"
159 case "task_added":
160 return "carbon:add-alt"
161 case "task_status_changed":
162 return "carbon:checkmark"
163 case "task_commented":
164 return "carbon:notebook"
165 default:
166 return "carbon:circle-dash"
167 }
168 }
169
170 function hasDetail(event: CaseEvent): boolean {
171 const p = (event.payload || {}) as Record<string, any>
172 return !!(p.snippet || (event.event_type === "alert_linked" && p.alert_ids))
173 }
174
175 function renderDetail(event: CaseEvent) {
176 const p = (event.payload || {}) as Record<string, any>
177 if (event.event_type === "comment_added" && p.snippet) {
178 return () => h("blockquote", { class: "border-border mt-1 border-l-4 pl-3 italic" }, String(p.snippet))
179 }
180 if (event.event_type === "task_commented" && p.snippet) {
181 return () => h("blockquote", { class: "border-border mt-1 border-l-4 pl-3 italic" }, String(p.snippet))
182 }
183 if (event.event_type === "alert_linked" && Array.isArray(p.alert_ids)) {
184 return () =>
185 h(
186 "span",
187 { class: "text-tertiary" },
188 `Alerts: ${(p.alert_ids as number[]).map((n: number) => `#${n}`).join(", ")}`
189 )
190 }
191 return () => h("span")
192 }
193
194 defineExpose({ refresh: fetchTimeline })
195
196 onBeforeMount(fetchTimeline)
197 </script>