main
vue 359 lines 9.44 KB
Raw
1 <template>
2 <div class="flex flex-col gap-4">
3 <div class="flex flex-col gap-2">
4 <h2>Case Templates</h2>
5 <p>
6 Reusable investigation playbooks. Templates are matched to new cases by customer + alert source on case
7 creation, with priority customer+source &gt; customer-only &gt; source-only &gt; global default.
8 </p>
9 </div>
10
11 <div class="@container mt-2 grid grid-cols-12 items-center gap-3">
12 <n-input
13 v-model:value="search"
14 size="small"
15 placeholder="Search by name or description"
16 clearable
17 class="col-span-full @3xl:col-span-4 @6xl:col-span-5"
18 >
19 <template #prefix><Icon name="carbon:search" /></template>
20 </n-input>
21 <n-select
22 v-model:value="customerFilter"
23 size="small"
24 :options="customersOptions"
25 placeholder="Customer code (blank = all)"
26 :loading="loadingCustomers"
27 filterable
28 clearable
29 class="col-span-full @lg:col-span-6 @3xl:col-span-3"
30 :consistent-menu-width="false"
31 />
32 <n-select
33 v-model:value="sourceFilter"
34 :options="sourcesOptions"
35 :consistent-menu-width="false"
36 placeholder="Alert source (blank = all)"
37 size="small"
38 filterable
39 clearable
40 class="col-span-full @lg:col-span-6 @3xl:col-span-3 @6xl:col-span-2"
41 :loading="loadingConfiguredSources"
42 />
43 <n-checkbox v-model:checked="includeGlobal" size="small" class="col-span-full @3xl:col-span-2">
44 <div class="text-xs">Include global / source-agnostic</div>
45 </n-checkbox>
46 </div>
47
48 <n-data-table :columns :data="filteredRows" :loading size="small" />
49
50 <!-- Editor modal -->
51 <n-modal
52 v-model:show="showEditor"
53 preset="card"
54 :title="editing ? `Edit template — ${editing.name}` : 'New template'"
55 display-directive="show"
56 style="max-width: 720px"
57 >
58 <CaseTemplateEditor :template="editing" @saved="onTemplateSaved" @cancel="showEditor = false" />
59 </n-modal>
60 </div>
61 </template>
62
63 <script setup lang="tsx">
64 import type { DataTableColumns } from "naive-ui"
65 import type { ApiError } from "@/types/common"
66 import type { Customer } from "@/types/customers"
67 import type { CaseTemplate } from "@/types/incidentManagement/caseTemplates.d"
68 import type { SourceName } from "@/types/incidentManagement/sources"
69 import { useDebounceFn } from "@vueuse/core"
70 import { NButton, NCheckbox, NDataTable, NInput, NModal, NSelect, NTag, useDialog, useMessage } from "naive-ui"
71 import { computed, onBeforeMount, ref, watch } from "vue"
72 import Api from "@/api"
73 import Icon from "@/components/common/Icon.vue"
74 import { useSettingsStore } from "@/stores/settings"
75 import { getApiErrorMessage } from "@/utils"
76 import { formatDate } from "@/utils/format"
77 import CaseTemplateEditor from "./CaseTemplateEditor.vue"
78
79 const message = useMessage()
80 const dialog = useDialog()
81
82 const dFormats = useSettingsStore().dateFormat
83 const templates = ref<CaseTemplate[]>([])
84 const loading = ref(false)
85 const deletingId = ref<number | null>(null)
86 const search = ref<string | null>(null)
87 const customerFilter = ref<string | null>(null)
88 const sourceFilter = ref<string | null>(null)
89 const includeGlobal = ref(true)
90
91 const loadingCustomers = ref(false)
92 const customersList = ref<Customer[]>([])
93 const customersOptions = computed(() =>
94 customersList.value.map(o => ({ label: `#${o.customer_code} - ${o.customer_name}`, value: o.customer_code }))
95 )
96
97 const loadingConfiguredSources = ref(false)
98 const configuredSourcesList = ref<SourceName[]>([])
99 const sourcesOptions = computed(() => configuredSourcesList.value.map(o => ({ label: o, value: o })))
100
101 const showEditor = ref(false)
102 const editing = ref<CaseTemplate | null>(null)
103
104 function renderCustomerCode(customerCode: string | null | undefined) {
105 if (customerCode) {
106 return <span class="font-mono">{customerCode}</span>
107 }
108
109 return <em class="text-tertiary">any</em>
110 }
111
112 function renderSource(source: string | null | undefined) {
113 if (source) {
114 return <span class="font-mono">{source}</span>
115 }
116
117 return <em class="text-tertiary">any</em>
118 }
119
120 const filteredRows = computed(() => {
121 if (!search.value?.trim()) return templates.value
122
123 const text = search.value.trim().toLowerCase()
124
125 return templates.value.filter(
126 t =>
127 t.name.toLowerCase().includes(text) ||
128 (t.description ?? "").toLowerCase().includes(text) ||
129 (t.customer_code ?? "").toLowerCase().includes(text) ||
130 (t.source ?? "").toLowerCase().includes(text)
131 )
132 })
133
134 const columns: DataTableColumns<CaseTemplate> = [
135 {
136 title: "Name",
137 key: "name",
138 width: 480,
139 render: row => (
140 <div class="flex flex-col gap-1">
141 <div class="flex items-center gap-2">
142 <span class="font-medium">{row.name}</span>
143 {row.is_default && (
144 <NTag size="tiny" type="info" bordered={false}>
145 default
146 </NTag>
147 )}
148 </div>
149 {row.description && <span class="text-secondary text-xs">{row.description}</span>}
150 </div>
151 )
152 },
153 {
154 title: "Scope",
155 key: "scope",
156 className: "whitespace-nowrap",
157 render: row => (
158 <div class="flex flex-col text-sm whitespace-nowrap">
159 <span>
160 <span class="text-secondary">customer: </span>
161 {renderCustomerCode(row.customer_code)}
162 </span>
163 <span>
164 <span class="text-secondary">source: </span>
165 {renderSource(row.source)}
166 </span>
167 </div>
168 )
169 },
170 {
171 title: "Tasks",
172 key: "tasks",
173 width: 110,
174 className: "whitespace-nowrap",
175 render(row) {
176 const total = row.tasks?.length ?? 0
177 const mandatory = row.tasks?.filter(t => t.mandatory).length ?? 0
178
179 return (
180 <div class="flex flex-col text-sm whitespace-nowrap">
181 <span>
182 <span class="font-mono">{total}</span>
183 {" total"}
184 </span>
185 {mandatory > 0 && (
186 <span class="text-warning">
187 <span class="font-mono">{mandatory}</span>
188 {" mandatory"}
189 </span>
190 )}
191 </div>
192 )
193 }
194 },
195 {
196 title: "Created by",
197 key: "created_by",
198 className: "whitespace-nowrap",
199 render: row => <span class="whitespace-nowrap">{row.created_by}</span>
200 },
201 {
202 title: "Updated",
203 key: "updated_at",
204 className: "whitespace-nowrap",
205 render: row => (
206 <span class="font-mono text-xs whitespace-nowrap">{formatDate(row.updated_at, dFormats.datetime)}</span>
207 )
208 },
209 {
210 title: "Actions",
211 key: "actions",
212 width: 120,
213 fixed: "right",
214 className: "whitespace-nowrap",
215 render: row => (
216 <div class="flex gap-2">
217 <NButton
218 size="small"
219 secondary
220 onClick={() => openEdit(row)}
221 v-slots={{ icon: () => <Icon name="carbon:edit" size={14} /> }}
222 >
223 Edit
224 </NButton>
225 <NButton
226 size="small"
227 secondary
228 type="error"
229 loading={deletingId.value === row.id}
230 onClick={() => confirmDelete(row)}
231 v-slots={{ icon: () => <Icon name="carbon:trash-can" size={14} /> }}
232 >
233 Delete
234 </NButton>
235 </div>
236 )
237 }
238 ]
239
240 const fetchTemplates = useDebounceFn(() => {
241 loading.value = true
242
243 Api.incidentManagement.caseTemplates
244 .listTemplates({
245 customerCode: customerFilter.value || undefined,
246 source: sourceFilter.value || undefined,
247 includeGlobal: includeGlobal.value
248 })
249 .then(res => {
250 if (res.data.success) {
251 templates.value = res.data.templates
252 } else {
253 message.warning(res.data.message)
254 }
255 })
256 .catch(err => {
257 message.error(getApiErrorMessage(err as ApiError) || "Failed to load templates")
258 })
259 .finally(() => {
260 loading.value = false
261 })
262 }, 400)
263
264 function openEdit(row: CaseTemplate) {
265 editing.value = row
266 showEditor.value = true
267 }
268
269 function confirmDelete(row: CaseTemplate) {
270 dialog.warning({
271 title: `Delete template "${row.name}" ?`,
272 content:
273 "Deleting the template removes its task definitions. Existing CaseTask snapshots on real cases are preserved (they're independent of the template).",
274 positiveText: "Delete",
275 negativeText: "Cancel",
276 onPositiveClick: () => {
277 deletingId.value = row.id
278
279 Api.incidentManagement.caseTemplates
280 .deleteTemplate(row.id)
281 .then(res => {
282 if (res.data.success) {
283 message.success(`Deleted "${row.name}"`)
284 fetchTemplates()
285 } else {
286 message.warning(res.data.message)
287 }
288 })
289 .catch(err => {
290 message.error(getApiErrorMessage(err as ApiError) || "Failed to delete template")
291 })
292 .finally(() => {
293 deletingId.value = null
294 })
295 }
296 })
297 }
298
299 function getCustomers() {
300 loadingCustomers.value = true
301
302 Api.customers
303 .getCustomers()
304 .then(res => {
305 if (res.data.success) {
306 customersList.value = res.data?.customers || []
307 } else {
308 message.warning(res.data?.message || "An error occurred. Please try again later.")
309 }
310 })
311 .catch(err => {
312 message.error(getApiErrorMessage(err as ApiError) || "An error occurred. Please try again later.")
313 })
314 .finally(() => {
315 loadingCustomers.value = false
316 })
317 }
318
319 function getConfiguredSources() {
320 loadingConfiguredSources.value = true
321
322 Api.incidentManagement.sources
323 .getConfiguredSources()
324 .then(res => {
325 if (res.data.success) {
326 configuredSourcesList.value = res.data?.sources || []
327 } else {
328 message.warning(res.data?.message || "An error occurred. Please try again later.")
329 }
330 })
331 .catch(err => {
332 message.error(getApiErrorMessage(err as ApiError) || "An error occurred. Please try again later.")
333 })
334 .finally(() => {
335 loadingConfiguredSources.value = false
336 })
337 }
338
339 function onTemplateSaved() {
340 showEditor.value = false
341 fetchTemplates()
342 }
343
344 // Re-fetch when scope filters change so the result set follows the
345 // backend's customer+source filtering semantics.
346 watch([customerFilter, sourceFilter, includeGlobal], () => {
347 fetchTemplates()
348 })
349
350 onBeforeMount(() => {
351 fetchTemplates()
352 getCustomers()
353 getConfiguredSources()
354 })
355
356 defineExpose({
357 fetchTemplates
358 })
359 </script>