| 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> |