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