Implement parent child ticket hierarchy

This commit is contained in:
2026-03-25 08:35:17 +01:00
parent 1ff8f5d68f
commit 3b31e6bb7d
13 changed files with 617 additions and 46 deletions
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { Badge } from '@/components/ui/badge';
import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
defineProps<{
nodes: TicketTreeNodeModel[];
projectId: number;
}>();
</script>
<template>
<ul class="space-y-3">
<li v-for="node in nodes" :key="node.id">
<div class="rounded-xl border border-border px-4 py-4">
<div class="flex flex-wrap items-center gap-2">
<Link
:href="
showTicket({
project: projectId,
ticket: node.id,
})
"
class="font-medium text-foreground transition hover:text-primary"
>
{{ node.ticket_key }}
</Link>
<Badge v-if="node.ticket_type_name" variant="secondary">
{{ node.ticket_type_name }}
</Badge>
</div>
<div class="mt-2 text-sm text-foreground">
{{ node.title }}
</div>
</div>
<div
v-if="node.children.length > 0"
class="mt-3 border-l border-border pl-4"
>
<TicketTreeList
:nodes="node.children"
:project-id="projectId"
/>
</div>
</li>
</ul>
</template>
+106 -39
View File
@@ -3,6 +3,7 @@ import { useForm, Head, Link } from '@inertiajs/vue3';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import TicketTreeList from '@/components/TicketTreeList.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -57,6 +58,7 @@ const form = useForm({
value_user_id: currentFieldValue?.value_user_id ?? '',
};
}),
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
title: props.ticket.title,
@@ -220,6 +222,34 @@ const formatDateTime = (value: string): string => {
</div>
</div>
<div class="grid gap-2">
<Label for="parent_ticket_id">
Parent ticket
</Label>
<select
id="parent_ticket_id"
v-model="form.parent_ticket_id"
:disabled="
!canEditTicket || form.processing
"
class="rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="">No parent ticket</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="parentTicketOption.id"
>
{{
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
}}
</option>
</select>
<InputError
:message="form.errors.parent_ticket_id"
/>
</div>
<div class="grid gap-2">
<Label for="description">Description</Label>
<textarea
@@ -357,53 +387,90 @@ const formatDateTime = (value: string): string => {
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>Worklog</CardTitle>
<CardDescription>
Every tracked ticket change is recorded with actor
and timestamp.
</CardDescription>
</CardHeader>
<div class="space-y-6">
<Card class="h-fit">
<CardHeader>
<CardTitle>Child tickets</CardTitle>
<CardDescription>
Parent tickets expose their full child tree
here, including nested parent chains.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="(ticket.worklog_entries?.length ?? 0) === 0"
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
>
No worklog entries exist for this ticket yet.
</div>
<div v-else class="flex flex-col gap-4">
<CardContent>
<div
v-for="worklogEntry in ticket.worklog_entries"
:key="worklogEntry.id"
class="rounded-xl border border-border px-4 py-4"
v-if="childTicketTree.length === 0"
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{{ worklogEntry.entry_type }}
</Badge>
<span
class="text-sm font-medium text-foreground"
No child tickets are assigned to this ticket
yet.
</div>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="project.id"
/>
</CardContent>
</Card>
<Card class="h-fit">
<CardHeader>
<CardTitle>Worklog</CardTitle>
<CardDescription>
Every tracked ticket change is recorded with
actor and timestamp.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="
(ticket.worklog_entries?.length ?? 0) === 0
"
class="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground"
>
No worklog entries exist for this ticket yet.
</div>
<div v-else class="flex flex-col gap-4">
<div
v-for="worklogEntry in ticket.worklog_entries"
:key="worklogEntry.id"
class="rounded-xl border border-border px-4 py-4"
>
<div
class="flex flex-wrap items-center gap-2"
>
<Badge variant="outline">
{{ worklogEntry.entry_type }}
</Badge>
<span
class="text-sm font-medium text-foreground"
>
{{
worklogEntry.actor?.name ??
'System'
}}
</span>
</div>
<p class="mt-3 text-sm text-foreground">
{{ worklogEntry.summary }}
</p>
<p
class="mt-2 text-xs text-muted-foreground"
>
{{
worklogEntry.actor?.name ?? 'System'
formatDateTime(
worklogEntry.created_at,
)
}}
</span>
</p>
</div>
<p class="mt-3 text-sm text-foreground">
{{ worklogEntry.summary }}
</p>
<p class="mt-2 text-xs text-muted-foreground">
{{
formatDateTime(worklogEntry.created_at)
}}
</p>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</div>
</div>
</div>
</AppLayout>
@@ -9,6 +9,7 @@ export type TicketModel = {
board_column_id: number | null;
description: string | null;
field_values?: TicketFieldValueModel[];
parent_ticket_id: number | null;
project_id: number;
sequence_number: number;
ticket_key: string;
@@ -0,0 +1,7 @@
export type TicketTreeNodeModel = {
children: TicketTreeNodeModel[];
id: number;
ticket_key: string;
ticket_type_name: string | null;
title: string;
};
+7
View File
@@ -4,6 +4,7 @@ import type { TeamModel } from '@/types/models/team-model';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { UserModel } from '@/types/models/user-model';
@@ -82,6 +83,12 @@ export type ProjectBacklogPageProps = {
export type ProjectTicketPageProps = {
canEditTicket: boolean;
childTicketTree: TicketTreeNodeModel[];
parentTicketOptions: Array<{
id: number;
ticket_key: string;
title: string;
}>;
project: ProjectModel;
status?: string | null;
ticket: TicketModel;