main
vue 285 lines 8.02 KB
Raw
1 <template>
2 <CardEntity
3 :status="taskData?.status === 'DONE' ? 'success' : taskData?.status === 'NOT_NECESSARY' ? 'warning' : undefined"
4 :embedded
5 :hide-header-extra="!taskData"
6 :hide-main-extra="!taskData"
7 :hide-footer="!taskData"
8 >
9 <template #headerMain>
10 <div class="flex flex-wrap items-center gap-3">
11 <span class="text-default font-sans text-base">
12 {{ taskData?.title }}
13 </span>
14
15 <n-tag v-if="taskData?.mandatory" :bordered="false" type="error" size="small">mandatory</n-tag>
16 <n-tag v-if="taskData?.template_task_id == null" :bordered="false" type="default" size="small">
17 custom
18 </n-tag>
19 </div>
20 </template>
21 <template #headerExtra>
22 <n-select
23 v-if="canEdit && taskData"
24 v-model:value="taskData.status"
25 :options="statusOptions"
26 :status="
27 taskData.status === 'DONE' ? 'success' : taskData.status === 'NOT_NECESSARY' ? 'warning' : undefined
28 "
29 size="small"
30 class="w-38!"
31 :consistent-menu-width="false"
32 :loading="savingStatus"
33 />
34 <n-tag v-else-if="taskData" :bordered="false" :type="statusTagType(taskData.status)" size="small">
35 {{ statusLabel(taskData.status) }}
36 </n-tag>
37 </template>
38 <template #default>
39 <div class="flex flex-col gap-3">
40 <p v-if="taskData?.description" class="text-secondary text-sm">{{ taskData.description }}</p>
41
42 <details v-if="taskData?.guidelines" class="text-sm">
43 <summary class="cursor-pointer font-medium">Guidelines</summary>
44 <p class="text-secondary mt-1 whitespace-pre-line">{{ taskData.guidelines }}</p>
45 </details>
46 </div>
47 </template>
48 <template #mainExtra>
49 <div class="flex flex-col gap-1">
50 <div class="flex items-center justify-between">
51 <div class="text-secondary text-xs uppercase">Evidence / notes</div>
52
53 <div
54 class="text-secondary text-right text-xs opacity-0 transition-opacity duration-300"
55 :class="{ 'animate-pulse opacity-100': savingEvidenceComment }"
56 >
57 saving...
58 </div>
59 </div>
60 <div v-if="canEdit && taskData" class="flex flex-col gap-1">
61 <n-input
62 v-model:value="taskData.evidence_comment"
63 type="textarea"
64 clearable
65 placeholder="Logs, command output, links — what proves this was done?"
66 :autosize="{ minRows: 2, maxRows: 8 }"
67 />
68 </div>
69 <p v-else-if="taskData?.evidence_comment" class="text-sm whitespace-pre-line">
70 {{ taskData.evidence_comment }}
71 </p>
72 <p v-else class="text-tertiary text-sm italic">No notes recorded</p>
73 </div>
74 </template>
75 <template #footer>
76 <div v-if="taskData" class="flex flex-wrap items-center justify-between gap-2">
77 <div class="text-secondary flex flex-wrap gap-x-4 gap-y-1 text-sm">
78 <span v-if="taskData.completed_by">
79 {{ task.status === "DONE" ? "Completed" : "Marked" }} by
80 <strong>{{ taskData.completed_by }}</strong>
81 <template v-if="taskData.completed_at">
82 · {{ formatDate(taskData.completed_at, dFormats.datetime) }}
83 </template>
84 </span>
85 <span v-else>
86 Created by
87 <strong>{{ task.created_by }}</strong>
88 </span>
89 </div>
90
91 <div>
92 <div class="flex items-center justify-end gap-2">
93 <n-button
94 v-if="canEdit && taskData.template_task_id == null"
95 size="tiny"
96 quaternary
97 type="error"
98 :loading="deleting"
99 @click="confirmDelete(taskData)"
100 >
101 <template #icon>
102 <Icon :name="DeleteIcon" />
103 </template>
104 Delete
105 </n-button>
106 </div>
107 </div>
108 </div>
109 </template>
110 </CardEntity>
111 </template>
112
113 <script setup lang="ts">
114 import type { ApiError } from "@/types/common"
115 import type { CaseTask, CaseTaskStatus } from "@/types/incidentManagement/caseTemplates.d"
116 import { useDebounceFn } from "@vueuse/core"
117 import axios from "axios"
118 import { NButton, NInput, NSelect, NTag, useDialog, useMessage } from "naive-ui"
119 import { computed, ref, watch } from "vue"
120 import Api from "@/api"
121 import CardEntity from "@/components/common/cards/CardEntity.vue"
122 import Icon from "@/components/common/Icon.vue"
123 import { useSettingsStore } from "@/stores/settings"
124 import { getApiErrorMessage } from "@/utils"
125 import { formatDate } from "@/utils/format"
126
127 const props = defineProps<{
128 task: CaseTask
129 caseId: number
130 canEdit: boolean
131 embedded?: boolean
132 }>()
133
134 const emit = defineEmits<{
135 (e: "updated", value: CaseTask): void
136 (e: "deleted"): void
137 }>()
138
139 const DeleteIcon = "carbon:trash-can"
140
141 const message = useMessage()
142 const dialog = useDialog()
143 const dFormats = useSettingsStore().dateFormat
144 const taskData = ref<CaseTask | null>(props.task)
145
146 const savingStatus = ref(false)
147 const savingEvidenceComment = ref(false)
148 const deleting = ref(false)
149
150 const statusOptions = computed(() => {
151 const opts: { label: string; value: CaseTaskStatus; disabled?: boolean }[] = [
152 { label: "To do", value: "TODO" },
153 { label: "Done", value: "DONE" },
154 { label: "Not necessary", value: "NOT_NECESSARY", disabled: taskData.value?.mandatory }
155 ]
156 return opts
157 })
158
159 let statusAbortController = new AbortController()
160 let evidenceCommentAbortController = new AbortController()
161
162 function statusLabel(status: CaseTaskStatus): string {
163 return status === "TODO" ? "To do" : status === "DONE" ? "Done" : "Not necessary"
164 }
165
166 function statusTagType(status: CaseTaskStatus) {
167 return status === "DONE" ? "success" : status === "NOT_NECESSARY" ? "warning" : "default"
168 }
169
170 const onStatusChange = useDebounceFn((newStatus: CaseTaskStatus) => {
171 if (!taskData.value) return
172
173 if (statusAbortController) {
174 statusAbortController.abort()
175 }
176
177 statusAbortController = new AbortController()
178 savingStatus.value = true
179
180 Api.incidentManagement.caseTemplates
181 .updateCaseTask(taskData.value.id, { status: newStatus }, statusAbortController.signal)
182 .then(res => {
183 if (res.data.success && res.data.task) {
184 emit("updated", res.data.task)
185 } else {
186 message.warning(res.data.message || "Status update rejected")
187 }
188 savingStatus.value = false
189 })
190 .catch(err => {
191 if (!axios.isCancel(err)) {
192 savingStatus.value = false
193 message.error(getApiErrorMessage(err as ApiError) || "Failed to update task status")
194 }
195 })
196 }, 250)
197
198 const onCommentChange = useDebounceFn((value: string | null) => {
199 if (!taskData.value) return
200
201 if (evidenceCommentAbortController) {
202 evidenceCommentAbortController.abort()
203 }
204
205 evidenceCommentAbortController = new AbortController()
206 savingEvidenceComment.value = true
207
208 Api.incidentManagement.caseTemplates
209 .updateCaseTask(taskData.value.id, { evidence_comment: value || "" }, evidenceCommentAbortController.signal)
210 .then(res => {
211 if (res.data.success && res.data.task) {
212 emit("updated", res.data.task)
213 } else {
214 message.warning(res.data.message)
215 }
216 savingEvidenceComment.value = false
217 })
218 .catch(err => {
219 if (!axios.isCancel(err)) {
220 savingEvidenceComment.value = false
221 message.error(getApiErrorMessage(err as ApiError) || "Failed to save evidence comment")
222 }
223 })
224 }, 500)
225
226 // Custom-task delete (analysts can only delete custom tasks via the chip — keeps
227 // template-derived audit trails intact unless an admin really wants to nuke it).
228 function confirmDelete(task: CaseTask) {
229 dialog.warning({
230 title: "Delete task?",
231 content: `"${task.title}" will be removed from this case.`,
232 positiveText: "Delete",
233 negativeText: "Cancel",
234 onPositiveClick: () => {
235 deleting.value = true
236
237 Api.incidentManagement.caseTemplates
238 .deleteCaseTask(task.id)
239 .then(res => {
240 if (res.data.success) {
241 emit("deleted")
242 } else {
243 message.warning(res.data.message)
244 }
245 })
246 .catch(err => {
247 message.error(getApiErrorMessage(err as ApiError) || "Failed to delete task")
248 })
249 .finally(() => {
250 deleting.value = false
251 })
252 }
253 })
254 }
255
256 watch(
257 () => taskData.value?.status,
258 val => {
259 if (val) {
260 onStatusChange(val)
261 }
262 }
263 )
264
265 watch(
266 () => taskData.value?.evidence_comment,
267 val => {
268 onCommentChange(val || null)
269 }
270 )
271 </script>
272
273 <style scoped lang="scss">
274 .task-card {
275 transition: background-color 0.15s ease;
276
277 &--done {
278 background-color: rgba(0, 200, 80, 0.05);
279 }
280
281 &--skipped {
282 background-color: rgba(160, 160, 160, 0.05);
283 }
284 }
285 </style>