Files
KanbanGit/resources/js/pages/projects/TicketCreate.vue
T

809 lines
41 KiB
Vue

<script setup lang="ts">
import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import TicketPriorityIcon from '@/components/TicketPriorityIcon.vue';
import TicketTypeIcon from '@/components/TicketTypeIcon.vue';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import AppLayout from '@/layouts/AppLayout.vue';
import {
buildAcceptanceCriteria,
parseChecklistLines,
} from '@/lib/acceptance-criteria';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import { index, show } from '@/routes/projects/index';
import { metadata as metadataSettings } from '@/routes/projects/settings';
import type { BreadcrumbItem, ProjectTicketCreatePageProps } from '@/types';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectTicketCreatePageProps>();
const frostedCardClass =
'gap-0 overflow-hidden rounded-[1.75rem] border-white/70 bg-white/85 shadow-[0_22px_65px_-40px_rgba(45,51,53,0.38)] backdrop-blur-sm dark:border-white/10 dark:bg-card/90 dark:shadow-black/25';
const airyOutlineButtonClass =
'rounded-full border-white/70 bg-white/80 text-[#2d3335] shadow-[0_18px_45px_-34px_rgba(45,51,53,0.34)] hover:bg-white dark:border-white/10 dark:bg-card/85 dark:text-foreground dark:hover:bg-card';
const inputClass =
'h-12 rounded-xl border-white/70 bg-white/80 text-[#2d3335] shadow-[0_18px_45px_-34px_rgba(45,51,53,0.22)] focus-visible:border-[#9de77c] focus-visible:ring-[#abf688]/35 dark:border-white/10 dark:bg-card/90 dark:text-foreground dark:focus-visible:border-primary';
const textareaClass =
'min-h-32 rounded-[1.25rem] border-white/70 bg-white/80 text-[#2d3335] shadow-[0_18px_45px_-34px_rgba(45,51,53,0.22)] focus-visible:border-[#9de77c] focus-visible:ring-[#abf688]/35 dark:border-white/10 dark:bg-card/90 dark:text-foreground dark:focus-visible:border-primary';
const selectTriggerClass =
'h-12 w-full rounded-xl border-white/70 bg-white/80 text-left text-[#2d3335] shadow-[0_18px_45px_-34px_rgba(45,51,53,0.22)] focus:border-[#9de77c] focus:ring-[#abf688]/35 dark:border-white/10 dark:bg-card/90 dark:text-foreground dark:focus:border-primary';
const selectContentClass =
'rounded-[1.25rem] border-white/80 bg-white/95 shadow-[0_24px_60px_-40px_rgba(45,51,53,0.34)] backdrop-blur-sm dark:border-white/10 dark:bg-card/95';
const fieldLabelClass =
'text-[0.72rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]';
const returnHref =
props.source === 'board'
? boardIndex(props.project.id)
: backlogIndex(props.project.id);
const returnLabel =
props.source === 'board' ? 'Back to board' : 'Back to backlog';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: props.source === 'board' ? 'Board' : 'Backlog',
href: returnHref,
},
{
title: 'Create ticket',
href: `/projects/${props.project.id}/tickets/create?source=${props.source}`,
},
];
const hasTicketMetadata =
props.ticketTypes.length > 0 &&
props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0;
const acceptanceCriteriaSummary = ref('');
const acceptanceCriteriaChecklist = ref('');
const form = useForm({
acceptance_criteria: buildAcceptanceCriteria(
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
),
assignee_user_id: '',
description: '',
story_points: '',
ticket_priority_id: String(props.ticketPriorities[0]?.id ?? ''),
ticket_status_id: String(
props.defaultTicketStatusId ?? props.ticketStatusOptions[0]?.id ?? '',
),
ticket_type_id: String(props.ticketTypes[0]?.id ?? ''),
title: '',
});
const acceptanceCriteriaPreviewItems = computed(() =>
parseChecklistLines(acceptanceCriteriaChecklist.value),
);
const acceptanceCriteriaError = computed(() => {
return (
form.errors.acceptance_criteria ??
form.errors['acceptance_criteria.summary'] ??
form.errors['acceptance_criteria.items'] ??
Object.entries(form.errors).find(([key]) =>
key.startsWith('acceptance_criteria.items.'),
)?.[1] ??
''
);
});
const selectedTicketType = computed(
() =>
props.ticketTypes.find(
(ticketType) => String(ticketType.id) === form.ticket_type_id,
) ?? null,
);
const selectedTicketPriority = computed(
() =>
props.ticketPriorities.find(
(ticketPriority) =>
String(ticketPriority.id) === form.ticket_priority_id,
) ?? null,
);
const selectedTicketStatus = computed(
() =>
props.ticketStatusOptions.find(
(ticketStatus) => String(ticketStatus.id) === form.ticket_status_id,
) ?? null,
);
const selectedAssignee = computed(
() =>
props.users.find((user) => String(user.id) === form.assignee_user_id) ??
null,
);
const submit = (): void => {
form.acceptance_criteria = buildAcceptanceCriteria(
acceptanceCriteriaSummary.value,
acceptanceCriteriaChecklist.value,
);
form.post(`/projects/${props.project.id}/tickets`, {
preserveScroll: true,
});
};
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
if (
ticketStatus.board_column === null ||
ticketStatus.board_column === undefined
) {
return ticketStatus.name;
}
if (ticketStatus.board_column.name === ticketStatus.name) {
return ticketStatus.name;
}
return `${ticketStatus.board_column.name} / ${ticketStatus.name}`;
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Create ${project.name} ticket`" />
<div class="relative isolate flex flex-1 flex-col gap-8 px-4 py-6">
<div
class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-72 bg-[radial-gradient(circle_at_top_left,rgba(171,246,136,0.34),transparent_42%),radial-gradient(circle_at_top_right,rgba(216,231,203,0.7),transparent_34%)]"
/>
<section
class="rounded-[2rem] border border-white/70 bg-[linear-gradient(135deg,rgba(255,255,255,0.94)_0%,rgba(241,244,245,0.9)_100%)] p-6 shadow-[0_28px_80px_-48px_rgba(45,51,53,0.4)] backdrop-blur-sm dark:border-white/10 dark:bg-[linear-gradient(135deg,rgba(17,20,20,0.92)_0%,rgba(24,28,28,0.88)_100%)] dark:shadow-black/30"
>
<div
class="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between"
>
<div class="max-w-3xl space-y-4">
<div class="flex flex-wrap items-center gap-2.5">
<Badge
variant="outline"
class="dark:bg-card/85 dark:text-muted-foreground rounded-full border-white/70 bg-white/80 px-3 py-1 text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-[#5a6062] shadow-[0_16px_40px_-34px_rgba(45,51,53,0.3)] dark:border-white/10"
>
{{ project.key }}
</Badge>
<Badge
variant="outline"
class="dark:bg-card/80 dark:text-foreground gap-2 rounded-full border-white/70 bg-white/70 px-3 py-1.5 text-[#2d3335] shadow-[0_16px_40px_-34px_rgba(45,51,53,0.3)] dark:border-white/10"
>
<TicketTypeIcon class="size-5" />
{{
selectedTicketType?.name ?? 'Choose a type'
}}
</Badge>
<Badge
variant="outline"
class="dark:bg-card/80 dark:text-foreground gap-2 rounded-full border-white/70 bg-white/70 px-3 py-1.5 text-[#2d3335] shadow-[0_16px_40px_-34px_rgba(45,51,53,0.3)] dark:border-white/10"
>
<TicketPriorityIcon class="size-5" />
{{
selectedTicketPriority?.name ??
'Choose a priority'
}}
</Badge>
</div>
<div class="space-y-3">
<p
class="dark:text-muted-foreground text-[0.72rem] font-semibold uppercase tracking-[0.22em] text-[#5a6062]"
>
Ticket creation
</p>
<h1
class="font-display dark:text-foreground text-3xl font-semibold tracking-tight text-[#2d3335] md:text-4xl"
>
Create a ticket with the new Stitch workspace
styling.
</h1>
<p
class="dark:text-muted-foreground max-w-2xl text-sm leading-7 text-[#5a6062]"
>
Build out scope, intent, and acceptance criteria
in one pass. The ticket key is created
automatically once the draft is saved.
</p>
</div>
</div>
<div class="flex flex-wrap gap-3">
<Button
as-child
variant="outline"
:class="airyOutlineButtonClass"
>
<Link :href="returnHref">{{ returnLabel }}</Link>
</Button>
<Button
as-child
variant="outline"
:class="airyOutlineButtonClass"
>
<Link :href="show(project.id)"
>Back to project</Link
>
</Button>
</div>
</div>
</section>
<div
v-if="status"
class="rounded-[1.5rem] border border-emerald-200/80 bg-emerald-50/80 px-5 py-4 text-sm text-emerald-900 shadow-[0_20px_45px_-38px_rgba(16,185,129,0.45)] dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-100"
>
{{ status }}
</div>
<div
v-if="hasTicketMetadata"
class="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_22rem]"
>
<Card :class="frostedCardClass">
<CardHeader class="border-b border-[#adb3b5]/20 pb-6">
<CardTitle
class="font-display dark:text-foreground text-2xl text-[#2d3335]"
>
Ticket blueprint
</CardTitle>
<CardDescription class="max-w-2xl leading-6">
Capture the essence of the work first, then define
the delivery frame with type, priority, status, and
ownership.
</CardDescription>
</CardHeader>
<CardContent class="p-6">
<form class="grid gap-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="title" :class="fieldLabelClass">
Title
</Label>
<Input
id="title"
v-model="form.title"
required
autofocus
:disabled="form.processing"
placeholder="Implement repository sync"
:class="inputClass"
/>
<InputError :message="form.errors.title" />
</div>
<div
class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]"
>
<div class="grid gap-6">
<div class="grid gap-2">
<Label
for="description"
:class="fieldLabelClass"
>
Description
</Label>
<Textarea
id="description"
v-model="form.description"
:disabled="form.processing"
placeholder="Describe the expected behavior, context, and why this work matters."
:class="textareaClass"
/>
<InputError
:message="form.errors.description"
/>
</div>
<div class="grid gap-4">
<div class="grid gap-2">
<Label
for="acceptance_criteria_summary"
:class="fieldLabelClass"
>
Acceptance criteria summary
</Label>
<Input
id="acceptance_criteria_summary"
v-model="acceptanceCriteriaSummary"
:disabled="form.processing"
placeholder="In one sentence, what defines done?"
:class="inputClass"
/>
</div>
<div class="grid gap-2">
<Label
for="acceptance_criteria_checklist"
:class="fieldLabelClass"
>
Acceptance checklist
</Label>
<Textarea
id="acceptance_criteria_checklist"
v-model="acceptanceCriteriaChecklist"
:disabled="form.processing"
placeholder="[ ] User can sign in\n[ ] Errors are shown inline\n [x] Nested checks use two-space indent"
:class="textareaClass"
/>
<p
class="text-muted-foreground text-xs leading-5"
>
Each line is a checklist item.
Prefix with
<span class="font-semibold"
>[x]</span
>
for checked items and indent with
two spaces for nested items.
</p>
</div>
<InputError
:message="acceptanceCriteriaError"
/>
<div
class="dark:bg-muted/25 grid gap-3 rounded-[1.25rem] border border-dashed border-[#adb3b5]/35 bg-[#f8f9fa]/70 p-4 dark:border-white/10"
>
<p
class="text-[0.72rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]"
>
Checklist preview
</p>
<div
v-if="
acceptanceCriteriaPreviewItems.length ===
0
"
class="text-muted-foreground rounded-xl border border-dashed px-3 py-3 text-sm"
>
Add checklist lines to preview
nested items.
</div>
<div v-else class="space-y-2">
<div
v-for="(
checklistItem,
checklistItemIndex
) in acceptanceCriteriaPreviewItems"
:key="checklistItemIndex"
class="flex items-start gap-3"
:style="{
marginLeft: `${checklistItem.indent * 1.25}rem`,
}"
>
<Checkbox
:checked="
checklistItem.checked
"
disabled
class="mt-0.5"
/>
<span
class="text-sm"
:class="
checklistItem.checked
? 'text-muted-foreground line-through'
: 'text-foreground'
"
>
{{ checklistItem.text }}
</span>
</div>
</div>
</div>
</div>
</div>
<div
class="dark:bg-muted/40 grid h-fit gap-5 rounded-[1.5rem] bg-[#f1f4f5]/90 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.75)]"
>
<div class="grid gap-2">
<Label
for="ticket_type_id"
:class="fieldLabelClass"
>
Type
</Label>
<Select
v-model="form.ticket_type_id"
:disabled="form.processing"
>
<SelectTrigger
id="ticket_type_id"
:class="selectTriggerClass"
>
<SelectValue
placeholder="Choose a type"
/>
</SelectTrigger>
<SelectContent
:class="selectContentClass"
>
<SelectItem
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="
String(ticketType.id)
"
>
<TicketTypeIcon
class="size-5"
/>
<span>
{{ ticketType.name }}
</span>
</SelectItem>
</SelectContent>
</Select>
<InputError
:message="
form.errors.ticket_type_id
"
/>
</div>
<div class="grid gap-2">
<Label
for="ticket_priority_id"
:class="fieldLabelClass"
>
Priority
</Label>
<Select
v-model="form.ticket_priority_id"
:disabled="form.processing"
>
<SelectTrigger
id="ticket_priority_id"
:class="selectTriggerClass"
>
<SelectValue
placeholder="Choose a priority"
/>
</SelectTrigger>
<SelectContent
:class="selectContentClass"
>
<SelectItem
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="
String(
ticketPriority.id,
)
"
>
<TicketPriorityIcon
class="size-5"
/>
<span>
{{
ticketPriority.name
}}
</span>
</SelectItem>
</SelectContent>
</Select>
<InputError
:message="
form.errors.ticket_priority_id
"
/>
</div>
<div class="grid gap-2">
<Label
for="ticket_status_id"
:class="fieldLabelClass"
>
Status
</Label>
<Select
v-model="form.ticket_status_id"
:disabled="form.processing"
>
<SelectTrigger
id="ticket_status_id"
:class="selectTriggerClass"
>
<SelectValue
placeholder="Choose a status"
/>
</SelectTrigger>
<SelectContent
:class="selectContentClass"
>
<SelectItem
v-for="ticketStatus in ticketStatusOptions"
:key="ticketStatus.id"
:value="
String(ticketStatus.id)
"
>
{{
ticketStatusLabel(
ticketStatus,
)
}}
</SelectItem>
</SelectContent>
</Select>
<InputError
:message="
form.errors.ticket_status_id
"
/>
</div>
<div class="grid gap-2">
<Label
for="assignee_user_id"
:class="fieldLabelClass"
>
Assignee
</Label>
<Select
v-model="form.assignee_user_id"
:disabled="form.processing"
>
<SelectTrigger
id="assignee_user_id"
:class="selectTriggerClass"
>
<SelectValue
placeholder="Unassigned"
/>
</SelectTrigger>
<SelectContent
:class="selectContentClass"
>
<SelectItem value="">
Unassigned
</SelectItem>
<SelectItem
v-for="user in users"
:key="user.id"
:value="String(user.id)"
>
{{ user.name }}
</SelectItem>
</SelectContent>
</Select>
<InputError
:message="
form.errors.assignee_user_id
"
/>
</div>
<div
v-if="project.show_story_points"
class="grid gap-2"
>
<Label
for="story_points"
:class="fieldLabelClass"
>
Story points
</Label>
<Input
id="story_points"
v-model="form.story_points"
type="number"
step="1"
:disabled="form.processing"
:class="inputClass"
/>
<InputError
:message="form.errors.story_points"
/>
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3 pt-2">
<Button
type="submit"
:disabled="form.processing"
class="rounded-full bg-[linear-gradient(135deg,#1e6000_0%,#2a6d0e_55%,#6ab04c_100%)] px-6 text-[#ecffdd] shadow-[0_20px_45px_-28px_rgba(42,109,14,0.7)] hover:opacity-95"
>
Create ticket
</Button>
<Button
as-child
variant="outline"
:class="airyOutlineButtonClass"
>
<Link :href="returnHref">
{{ returnLabel }}
</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
<div class="space-y-6 xl:sticky xl:top-24 xl:self-start">
<Card :class="frostedCardClass">
<CardHeader class="border-b border-[#adb3b5]/20 pb-5">
<CardTitle
class="font-display dark:text-foreground text-xl text-[#2d3335]"
>
Draft snapshot
</CardTitle>
<CardDescription>
Keep the setup visible while you shape the
ticket.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4 p-6">
<div
class="dark:bg-muted/40 rounded-[1.5rem] bg-[#f1f4f5] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.85)]"
>
<p
class="dark:text-muted-foreground text-[0.72rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]"
>
Ticket key
</p>
<p
class="font-display dark:text-foreground mt-2 text-2xl font-semibold text-[#2d3335]"
>
Assigned after creation
</p>
</div>
<div class="grid gap-3">
<div
class="dark:bg-muted/25 flex items-center justify-between rounded-[1.25rem] bg-[#f8f9fa] px-4 py-3"
>
<span
class="dark:text-muted-foreground text-sm text-[#5a6062]"
>
Type
</span>
<span
class="dark:text-foreground flex items-center gap-2 text-sm font-medium text-[#2d3335]"
>
<TicketTypeIcon class="size-5" />
{{
selectedTicketType?.name ??
'Choose a type'
}}
</span>
</div>
<div
class="dark:bg-muted/25 flex items-center justify-between rounded-[1.25rem] bg-[#f8f9fa] px-4 py-3"
>
<span
class="dark:text-muted-foreground text-sm text-[#5a6062]"
>
Priority
</span>
<span
class="dark:text-foreground flex items-center gap-2 text-sm font-medium text-[#2d3335]"
>
<TicketPriorityIcon class="size-5" />
{{
selectedTicketPriority?.name ??
'Choose a priority'
}}
</span>
</div>
<div
class="dark:bg-muted/25 flex items-center justify-between rounded-[1.25rem] bg-[#f8f9fa] px-4 py-3"
>
<span
class="dark:text-muted-foreground text-sm text-[#5a6062]"
>
Status
</span>
<span
class="dark:text-foreground text-sm font-medium text-[#2d3335]"
>
{{
selectedTicketStatus
? ticketStatusLabel(
selectedTicketStatus,
)
: 'Choose a status'
}}
</span>
</div>
<div
class="dark:bg-muted/25 flex items-center justify-between rounded-[1.25rem] bg-[#f8f9fa] px-4 py-3"
>
<span
class="dark:text-muted-foreground text-sm text-[#5a6062]"
>
Assignee
</span>
<span
class="dark:text-foreground text-sm font-medium text-[#2d3335]"
>
{{
selectedAssignee?.name ??
'Unassigned'
}}
</span>
</div>
</div>
<div
class="dark:bg-card/60 dark:text-muted-foreground rounded-[1.5rem] border border-dashed border-[#adb3b5]/45 bg-white/55 px-4 py-4 text-sm leading-6 text-[#5a6062] dark:border-white/10"
>
Ticket types, priorities, and statuses come
directly from the current project metadata.
</div>
</CardContent>
</Card>
</div>
</div>
<Card v-else :class="frostedCardClass">
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p
class="dark:text-muted-foreground leading-7 text-[#5a6062]"
>
Ticket creation needs at least one type, one priority,
and one status.
</p>
<div>
<Button
as-child
variant="outline"
:class="airyOutlineButtonClass"
>
<Link :href="metadataSettings(project.id)">
Open ticket settings
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</AppLayout>
</template>