mirror of
https://github.com/SecurityBrewery/catalyst.git
synced 2026-02-21 04:25:26 +01:00
256 lines
8.6 KiB
Vue
256 lines
8.6 KiB
Vue
<script lang="ts" setup>
|
|
import TanView from '@/components/TanView.vue'
|
|
import JSONSchemaFormFields from '@/components/form/JSONSchemaFormFields.vue'
|
|
import DynamicMDEditor from '@/components/input/DynamicMDEditor.vue'
|
|
import ColumnBody from '@/components/layout/ColumnBody.vue'
|
|
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
|
|
import StatusIcon from '@/components/ticket/StatusIcon.vue'
|
|
import TicketActionBar from '@/components/ticket/TicketActionBar.vue'
|
|
import TicketCloseBar from '@/components/ticket/TicketCloseBar.vue'
|
|
import TicketHeader from '@/components/ticket/TicketHeader.vue'
|
|
import TicketComments from '@/components/ticket/comment/TicketComments.vue'
|
|
import TicketFiles from '@/components/ticket/file/TicketFiles.vue'
|
|
import TicketLinks from '@/components/ticket/link/TicketLinks.vue'
|
|
import TicketTasks from '@/components/ticket/task/TicketTasks.vue'
|
|
import TicketTimeline from '@/components/ticket/timeline/TicketTimeline.vue'
|
|
import {
|
|
Accordion,
|
|
AccordionContent,
|
|
AccordionItem,
|
|
AccordionTrigger
|
|
} from '@/components/ui/accordion'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card } from '@/components/ui/card'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import { useToast } from '@/components/ui/toast/use-toast'
|
|
|
|
import { Edit } from 'lucide-vue-next'
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
|
import { computed, ref } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
|
|
import { useAPI } from '@/api'
|
|
import type {
|
|
ExtendedComment,
|
|
ExtendedTask,
|
|
ExtendedTicket,
|
|
Link,
|
|
ModelFile,
|
|
Ticket,
|
|
TimelineEntry,
|
|
Type
|
|
} from '@/client/models'
|
|
import { handleError } from '@/lib/utils'
|
|
|
|
const api = useAPI()
|
|
|
|
const route = useRoute()
|
|
const queryClient = useQueryClient()
|
|
const { toast } = useToast()
|
|
|
|
defineProps<{
|
|
selectedType: Type
|
|
}>()
|
|
|
|
const id = computed(() => route.params.id as string)
|
|
|
|
const message = ref('')
|
|
const editMode = ref(false)
|
|
|
|
const {
|
|
isPending,
|
|
isError,
|
|
data: ticket,
|
|
error
|
|
} = useQuery({
|
|
queryKey: ['tickets', id.value],
|
|
queryFn: (): Promise<ExtendedTicket> => api.getTicket({ id: id.value })
|
|
})
|
|
|
|
const { data: timeline } = useQuery({
|
|
queryKey: ['timeline', id.value],
|
|
queryFn: (): Promise<Array<TimelineEntry>> => api.listTimeline({ ticket: id.value })
|
|
})
|
|
|
|
const { data: tasks } = useQuery({
|
|
queryKey: ['tasks', id.value],
|
|
queryFn: (): Promise<Array<ExtendedTask>> => api.listTasks({ ticket: id.value })
|
|
})
|
|
|
|
const { data: comments } = useQuery({
|
|
queryKey: ['comments', id.value],
|
|
queryFn: (): Promise<Array<ExtendedComment>> => api.listComments({ ticket: id.value })
|
|
})
|
|
|
|
const { data: files } = useQuery({
|
|
queryKey: ['files', id.value],
|
|
queryFn: (): Promise<Array<ModelFile>> => api.listFiles({ ticket: id.value })
|
|
})
|
|
|
|
const { data: links } = useQuery({
|
|
queryKey: ['links', id.value],
|
|
queryFn: (): Promise<Array<Link>> => api.listLinks({ ticket: id.value })
|
|
})
|
|
|
|
const editDescriptionMutation = useMutation({
|
|
mutationFn: () =>
|
|
api.updateTicket({ id: id.value, ticketUpdate: { description: message.value } }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tickets', id.value] })
|
|
toast({
|
|
title: 'Ticket updated',
|
|
description: 'The ticket description has been updated'
|
|
})
|
|
editMode.value = false
|
|
},
|
|
onError: handleError('Failed to update description')
|
|
})
|
|
|
|
const edit = () => (editMode.value = true)
|
|
|
|
const editStateMutation = useMutation({
|
|
mutationFn: (state: Record<string, any>): Promise<Ticket> =>
|
|
api.updateTicket({ id: id.value, ticketUpdate: { state } }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tickets', id.value] })
|
|
toast({
|
|
title: 'Ticket updated',
|
|
description: 'The ticket state has been updated'
|
|
})
|
|
},
|
|
onError: handleError('Failed to update state')
|
|
})
|
|
|
|
const taskStatus = computed(() => {
|
|
if (!ticket.value) {
|
|
return 'pending'
|
|
}
|
|
|
|
if (tasks.value && tasks.value.every((task) => !task.open)) {
|
|
return 'completed'
|
|
}
|
|
|
|
if (tasks.value && tasks.value.every((task) => task.open)) {
|
|
return 'open'
|
|
}
|
|
|
|
return 'pending'
|
|
})
|
|
|
|
const updateDescription = (value: string | undefined) => (message.value = value ?? '')
|
|
</script>
|
|
|
|
<template>
|
|
<TanView :isError="isError" :isPending="isPending" :error="error">
|
|
<template v-if="ticket">
|
|
<TicketActionBar :ticket="ticket" />
|
|
<ColumnBody>
|
|
<ColumnBodyContainer class="flex-col gap-4 xl:flex-row">
|
|
<div class="flex flex-1 flex-col gap-4">
|
|
<TicketHeader :ticket="ticket" />
|
|
<Card class="relative p-4">
|
|
<Button
|
|
v-if="!editMode"
|
|
variant="outline"
|
|
class="float-right h-8 gap-2"
|
|
@click="edit"
|
|
>
|
|
<Edit class="h-3.5 w-3.5" />
|
|
<span>Edit</span>
|
|
</Button>
|
|
<DynamicMDEditor
|
|
:modelValue="ticket.description"
|
|
@update:modelValue="updateDescription"
|
|
v-model:edit="editMode"
|
|
autofocus
|
|
placeholder="Type a description..."
|
|
@save="editDescriptionMutation.mutate"
|
|
class="min-h-14"
|
|
/>
|
|
</Card>
|
|
<Accordion
|
|
type="multiple"
|
|
:default-value="['tasks', 'comments', 'timeline']"
|
|
class="w-full divide-y rounded-md border"
|
|
>
|
|
<AccordionItem value="tasks" class="border-0">
|
|
<AccordionTrigger class="px-3 py-2 hover:no-underline">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium">Tasks</span>
|
|
<Badge
|
|
v-if="tasks && tasks.length > 0"
|
|
variant="outline"
|
|
class="hidden sm:inline-flex"
|
|
>
|
|
{{ tasks.length }}
|
|
<StatusIcon :status="taskStatus" class="size-6" />
|
|
</Badge>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent class="px-3 pt-2">
|
|
<TicketTasks :ticket="ticket" :tasks="tasks" />
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
<AccordionItem value="comments" class="border-0">
|
|
<AccordionTrigger class="px-3 py-2 hover:no-underline">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium">Comments</span>
|
|
<Badge
|
|
v-if="comments && comments.length > 0"
|
|
variant="outline"
|
|
class="hidden sm:inline-flex"
|
|
>
|
|
{{ comments.length }}
|
|
</Badge>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent class="px-3 pt-2">
|
|
<TicketComments :ticket="ticket" :comments="comments" />
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
<AccordionItem value="timeline" class="border-0">
|
|
<AccordionTrigger class="px-3 py-2 hover:no-underline">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-sm font-medium">Timeline</span>
|
|
<Badge
|
|
v-if="timeline && timeline.length > 0"
|
|
variant="outline"
|
|
class="hidden sm:inline-flex"
|
|
>
|
|
{{ timeline.length }}
|
|
</Badge>
|
|
</div>
|
|
</AccordionTrigger>
|
|
<AccordionContent class="px-3 pt-2">
|
|
<TicketTimeline :ticket="ticket" :timeline="timeline" />
|
|
</AccordionContent>
|
|
</AccordionItem>
|
|
</Accordion>
|
|
<Separator class="xl:hidden" />
|
|
</div>
|
|
<div class="flex flex-col gap-4 xl:w-96 xl:flex-initial">
|
|
<div>
|
|
<div class="flex h-10 flex-row items-center justify-between">
|
|
<span class="text-sm font-medium"> Details </span>
|
|
</div>
|
|
<JSONSchemaFormFields
|
|
:modelValue="ticket.state"
|
|
@update:modelValue="editStateMutation.mutate"
|
|
:schema="selectedType.schema"
|
|
/>
|
|
</div>
|
|
<Separator />
|
|
<TicketLinks :ticket="ticket" :links="links" />
|
|
<Separator />
|
|
<TicketFiles :ticket="ticket" :files="files" />
|
|
</div>
|
|
</ColumnBodyContainer>
|
|
</ColumnBody>
|
|
<Separator />
|
|
<TicketCloseBar :ticket="ticket" />
|
|
</template>
|
|
</TanView>
|
|
</template>
|