Adopt Stitch styling for ticket pages

This commit is contained in:
2026-04-16 08:59:43 +02:00
parent ab86f68638
commit 550c837c5e
5 changed files with 1116 additions and 304 deletions
@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { MessageSquareDashed } from 'lucide-vue-next';
import { cn } from '@/lib/utils';
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class'];
description?: string;
feature: string;
}>(),
{
description:
'This area is already part of the Stitch concept and will be wired into the app in a later pass.',
},
);
</script>
<template>
<section
:class="
cn(
'rounded-[1.75rem] border border-[#e7de92] bg-[linear-gradient(180deg,#fffbe3_0%,#fdf6b0_100%)] p-5 text-[#615d27] shadow-[0_24px_60px_-42px_rgba(97,93,39,0.45)] dark:border-[#6c6830]/70 dark:bg-[linear-gradient(180deg,rgba(101,97,42,0.24)_0%,rgba(79,75,22,0.42)_100%)] dark:text-[#fdf6b0]',
props.class,
)
"
>
<div
class="inline-flex items-center rounded-full border border-[#ddd382] bg-white/65 px-3 py-1 text-[0.68rem] font-semibold uppercase tracking-[0.2em] text-[#6c6830] dark:border-[#6c6830] dark:bg-black/15 dark:text-[#fdf6b0]"
>
Coming soon
</div>
<div class="mt-4 flex items-start gap-4">
<div
class="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-white/70 text-[#65612a] shadow-[inset_0_1px_0_rgba(255,255,255,0.7)] dark:bg-black/20 dark:text-[#fdf6b0]"
>
<MessageSquareDashed class="size-5" />
</div>
<div class="space-y-2">
<h3 class="font-display text-lg font-semibold">
{{ feature }}
</h3>
<p class="text-sm leading-6 text-[#7d7737] dark:text-[#eee8a3]">
{{ description }}
</p>
</div>
</div>
</section>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { Flag } from 'lucide-vue-next';
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<span
:class="
cn(
'dark:bg-primary/20 dark:text-primary inline-flex size-6 items-center justify-center rounded-full bg-[#d8e7cb] text-[#1d5e00] shadow-[inset_0_1px_0_rgba(255,255,255,0.6)]',
props.class,
)
"
>
<Flag class="size-3.5" />
</span>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { Shapes } from 'lucide-vue-next';
import { cn } from '@/lib/utils';
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
</script>
<template>
<span
:class="
cn(
'dark:bg-secondary/40 dark:text-secondary-foreground inline-flex size-6 items-center justify-center rounded-full bg-[#dee3e6] text-[#485640] shadow-[inset_0_1px_0_rgba(255,255,255,0.6)]',
props.class,
)
"
>
<Shapes class="size-3.5" />
</span>
</template>
+437 -107
View File
@@ -5,9 +5,11 @@ import { computed, ref, watch } from 'vue';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController'; import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController'; import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue'; import FeatureComingSoonPanel from '@/components/FeatureComingSoonPanel.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import TicketPriorityIcon from '@/components/TicketPriorityIcon.vue';
import TicketTreeList from '@/components/TicketTreeList.vue'; import TicketTreeList from '@/components/TicketTreeList.vue';
import TicketTypeIcon from '@/components/TicketTypeIcon.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -25,8 +27,17 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; 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 AppLayout from '@/layouts/AppLayout.vue';
import { edit as editGitConnections } from '@/routes/git-connections/index'; import { edit as editGitConnections } from '@/routes/git-connections/index';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
@@ -103,6 +114,25 @@ const breadcrumbs: BreadcrumbItem[] = [
}, },
]; ];
const frostedCardClass =
'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 frostedAsideCardClass =
'h-fit 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 nativeSelectClass =
'h-12 w-full rounded-xl border border-white/70 bg-white/80 px-3 py-2 text-sm text-[#2d3335] shadow-[0_18px_45px_-34px_rgba(45,51,53,0.22)] outline-none transition-[color,box-shadow] focus:border-[#9de77c] focus:ring-4 focus:ring-[#abf688]/35 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-card/90 dark:text-foreground dark:focus:border-primary dark:focus:ring-primary/20';
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 form = useForm({ const form = useForm({
acceptance_criteria: props.ticket.acceptance_criteria ?? '', acceptance_criteria: props.ticket.acceptance_criteria ?? '',
assignee_user_id: props.ticket.assignee_user_id ?? '', assignee_user_id: props.ticket.assignee_user_id ?? '',
@@ -124,12 +154,56 @@ const form = useForm({
}), }),
is_blocked: props.ticket.is_blocked, is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id ?? '', parent_ticket_id: props.ticket.parent_ticket_id ?? '',
ticket_priority_id: props.ticket.ticket_priority_id, ticket_priority_id: String(props.ticket.ticket_priority_id),
ticket_type_id: props.ticket.ticket_type_id, ticket_type_id: String(props.ticket.ticket_type_id),
title: props.ticket.title, title: props.ticket.title,
story_points: props.ticket.story_points, story_points: props.ticket.story_points,
}); });
const selectedTicketType = computed(
() =>
props.ticketTypes.find(
(ticketType) => String(ticketType.id) === form.ticket_type_id,
) ?? props.ticket.ticket_type,
);
const selectedTicketPriority = computed(
() =>
props.ticketPriorities.find(
(ticketPriority) =>
String(ticketPriority.id) === form.ticket_priority_id,
) ?? props.ticket.ticket_priority,
);
const selectedAssignee = computed(
() =>
props.users.find(
(user) => String(user.id) === String(form.assignee_user_id),
) ?? null,
);
const selectedAuthor = computed(
() =>
props.users.find(
(user) => String(user.id) === String(form.author_user_id),
) ?? null,
);
const repositoryBranchCount = computed(() =>
visibleRepositoryBranches.value.reduce(
(count, repositoryBranch) => count + repositoryBranch.branches.length,
0,
),
);
const pullRequestCount = computed(() =>
visibleRepositoryPullRequests.value.reduce(
(count, repositoryPullRequest) =>
count + repositoryPullRequest.pull_requests.length,
0,
),
);
const isCreateBranchDialogOpen = ref(false); const isCreateBranchDialogOpen = ref(false);
const isCheckingBranchName = ref(false); const isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null); const branchNameValidation = ref<BranchNameValidationModel | null>(null);
@@ -271,8 +345,7 @@ watch(
branchNameValidation.value = { branchNameValidation.value = {
is_valid: false, is_valid: false,
message: message: `${appName} could not validate this branch name right now.`,
`${appName} could not validate this branch name right now.`,
}; };
} finally { } finally {
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
@@ -341,65 +414,139 @@ const formatDateTime = (value: string): string => {
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="ticket.ticket_key" /> <Head :title="ticket.ticket_key" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div class="relative isolate flex flex-1 flex-col gap-8 px-4 py-6">
<div <div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" 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="space-y-3"> <div
<div class="flex flex-wrap items-center gap-2"> class="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between"
<Badge variant="outline" class="px-3 py-1"> >
{{ ticket.ticket_key }} <div class="max-w-3xl space-y-4">
</Badge> <div class="flex flex-wrap items-center gap-2.5">
<Badge variant="secondary"> <Badge
{{ ticket.ticket_type.name }} variant="outline"
</Badge> 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"
<Badge variant="secondary"> >
{{ ticket.ticket_priority.name }} {{ ticket.ticket_key }}
</Badge> </Badge>
<Badge v-if="ticket.ticket_status" variant="outline">
{{ ticket.ticket_status.name }} <Badge
</Badge> variant="outline"
<Badge v-if="!canEditTicket" 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"
Read only >
</Badge> <TicketTypeIcon class="size-5" />
{{ selectedTicketType.name }}
</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 }}
</Badge>
<Badge
v-if="ticket.ticket_status"
variant="outline"
class="dark:bg-card/80 dark:text-foreground 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"
>
{{ ticket.ticket_status.name }}
</Badge>
<Badge
v-if="ticket.is_blocked"
class="rounded-full border-transparent bg-[#fdf6b0] px-3 py-1.5 text-[#615d27] dark:bg-[#65612a]/35 dark:text-[#fdf6b0]"
>
Blocked
</Badge>
<Badge
v-if="!canEditTicket"
variant="outline"
class="dark:bg-card/80 dark:text-muted-foreground rounded-full border-white/70 bg-white/70 px-3 py-1.5 text-[#5a6062] shadow-[0_16px_40px_-34px_rgba(45,51,53,0.3)] dark:border-white/10"
>
Read only
</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 detail
</p>
<h1
class="font-display dark:text-foreground text-3xl font-semibold tracking-tight text-[#2d3335] md:text-4xl"
>
{{ ticket.title }}
</h1>
<p
class="dark:text-muted-foreground max-w-2xl text-sm leading-7 text-[#5a6062]"
>
Review ticket details, update tracked fields,
and inspect the change timeline in the new
Stitch-inspired workspace.
</p>
</div>
</div> </div>
<Heading
:title="ticket.title"
description="Review ticket details, update tracked fields, and inspect the change timeline."
/>
</div>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<Button as-child variant="outline"> <Button
<Link :href="backlogIndex(project.id)" as-child
>Open backlog</Link variant="outline"
:class="airyOutlineButtonClass"
> >
</Button> <Link :href="backlogIndex(project.id)">
Open backlog
</Link>
</Button>
<Button as-child variant="outline"> <Button
<Link :href="boardIndex(project.id)">Open board</Link> as-child
</Button> variant="outline"
:class="airyOutlineButtonClass"
>
<Link :href="boardIndex(project.id)"
>Open board</Link
>
</Button>
<Button as-child variant="outline"> <Button
<Link :href="show(project.id)">Back to project</Link> as-child
</Button> variant="outline"
:class="airyOutlineButtonClass"
>
<Link :href="show(project.id)"
>Back to project</Link
>
</Button>
</div>
</div> </div>
</div> </section>
<div <div
v-if="status" v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100" 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 }} {{ status }}
</div> </div>
<div <div
class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]" class="grid gap-6 xl:grid-cols-[minmax(0,1.5fr)_minmax(320px,0.95fr)] 2xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.95fr)_minmax(280px,0.85fr)]"
> >
<Card> <Card :class="frostedCardClass">
<CardHeader> <CardHeader>
<CardTitle>Ticket details</CardTitle> <CardTitle
<CardDescription> class="font-display dark:text-foreground text-2xl text-[#2d3335]"
>
Ticket details
</CardTitle>
<CardDescription class="max-w-2xl leading-6">
{{ {{
canEditTicket canEditTicket
? 'Tracked ticket changes create worklog entries automatically.' ? 'Tracked ticket changes create worklog entries automatically.'
@@ -411,61 +558,104 @@ const formatDateTime = (value: string): string => {
<CardContent> <CardContent>
<form class="grid gap-6" @submit.prevent="submit"> <form class="grid gap-6" @submit.prevent="submit">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="title">Title</Label> <Label for="title" :class="fieldLabelClass">
Title
</Label>
<Input <Input
id="title" id="title"
v-model="form.title" v-model="form.title"
:disabled=" :disabled="
!canEditTicket || form.processing !canEditTicket || form.processing
" "
:class="inputClass"
/> />
<InputError :message="form.errors.title" /> <InputError :message="form.errors.title" />
</div> </div>
<div class="grid gap-2 md:grid-cols-2 md:gap-6"> <div class="grid gap-2 md:grid-cols-2 md:gap-6">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="ticket_type_id">Type</Label> <Label
<select for="ticket_type_id"
id="ticket_type_id" :class="fieldLabelClass"
>
Type
</Label>
<Select
v-model="form.ticket_type_id" v-model="form.ticket_type_id"
:disabled=" :disabled="
!canEditTicket || form.processing !canEditTicket || form.processing
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
> >
<option <SelectTrigger
v-for="ticketType in ticketTypes" id="ticket_type_id"
:key="ticketType.id" :class="selectTriggerClass"
:value="ticketType.id"
> >
{{ ticketType.name }} <SelectValue
</option> placeholder="Choose a type"
</select> />
</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 <InputError
:message="form.errors.ticket_type_id" :message="form.errors.ticket_type_id"
/> />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="ticket_priority_id"> <Label
for="ticket_priority_id"
:class="fieldLabelClass"
>
Priority Priority
</Label> </Label>
<select <Select
id="ticket_priority_id"
v-model="form.ticket_priority_id" v-model="form.ticket_priority_id"
:disabled=" :disabled="
!canEditTicket || form.processing !canEditTicket || form.processing
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
> >
<option <SelectTrigger
v-for="ticketPriority in ticketPriorities" id="ticket_priority_id"
:key="ticketPriority.id" :class="selectTriggerClass"
:value="ticketPriority.id"
> >
{{ ticketPriority.name }} <SelectValue
</option> placeholder="Choose a priority"
</select> />
</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 <InputError
:message=" :message="
form.errors.ticket_priority_id form.errors.ticket_priority_id
@@ -476,7 +666,10 @@ const formatDateTime = (value: string): string => {
<div class="grid gap-2 md:grid-cols-2 md:gap-6"> <div class="grid gap-2 md:grid-cols-2 md:gap-6">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="assignee_user_id"> <Label
for="assignee_user_id"
:class="fieldLabelClass"
>
Assignee Assignee
</Label> </Label>
<select <select
@@ -485,7 +678,7 @@ const formatDateTime = (value: string): string => {
:disabled=" :disabled="
!canEditTicket || form.processing !canEditTicket || form.processing
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60" :class="nativeSelectClass"
> >
<option value="">Unassigned</option> <option value="">Unassigned</option>
<option <option
@@ -502,14 +695,19 @@ const formatDateTime = (value: string): string => {
</div> </div>
<div v-if="canViewAuthor" class="grid gap-2"> <div v-if="canViewAuthor" class="grid gap-2">
<Label for="author_user_id">Author</Label> <Label
for="author_user_id"
:class="fieldLabelClass"
>
Author
</Label>
<select <select
id="author_user_id" id="author_user_id"
v-model="form.author_user_id" v-model="form.author_user_id"
:disabled=" :disabled="
!canEditAuthor || form.processing !canEditAuthor || form.processing
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60" :class="nativeSelectClass"
> >
<option value="">No author</option> <option value="">No author</option>
<option <option
@@ -530,7 +728,10 @@ const formatDateTime = (value: string): string => {
class="grid gap-2" class="grid gap-2"
v-if="project.show_story_points" v-if="project.show_story_points"
> >
<Label for="story_points"> <Label
for="story_points"
:class="fieldLabelClass"
>
Estimated story points Estimated story points
</Label> </Label>
<Input <Input
@@ -539,6 +740,7 @@ const formatDateTime = (value: string): string => {
v-model="form.story_points" v-model="form.story_points"
type="number" type="number"
step="1" step="1"
:class="inputClass"
/> />
<InputError <InputError
:message="form.errors.story_points" :message="form.errors.story_points"
@@ -546,7 +748,10 @@ const formatDateTime = (value: string): string => {
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="parent_ticket_id"> <Label
for="parent_ticket_id"
:class="fieldLabelClass"
>
Parent ticket Parent ticket
</Label> </Label>
<select <select
@@ -555,7 +760,7 @@ const formatDateTime = (value: string): string => {
:disabled=" :disabled="
!canEditTicket || form.processing !canEditTicket || form.processing
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60" :class="nativeSelectClass"
> >
<option value="">No parent ticket</option> <option value="">No parent ticket</option>
<option <option
@@ -574,15 +779,19 @@ const formatDateTime = (value: string): string => {
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="description">Description</Label> <Label
<textarea for="description"
:class="fieldLabelClass"
>
Description
</Label>
<Textarea
id="description" id="description"
v-model="form.description" v-model="form.description"
rows="5"
:disabled=" :disabled="
!canEditTicket || form.processing !canEditTicket || form.processing
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60" :class="textareaClass"
/> />
<InputError <InputError
:message="form.errors.description" :message="form.errors.description"
@@ -590,17 +799,19 @@ const formatDateTime = (value: string): string => {
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="acceptance_criteria"> <Label
for="acceptance_criteria"
:class="fieldLabelClass"
>
Acceptance criteria Acceptance criteria
</Label> </Label>
<textarea <Textarea
id="acceptance_criteria" id="acceptance_criteria"
v-model="form.acceptance_criteria" v-model="form.acceptance_criteria"
rows="5"
:disabled=" :disabled="
!canEditTicket || form.processing !canEditTicket || form.processing
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60" :class="textareaClass"
/> />
<InputError <InputError
:message="form.errors.acceptance_criteria" :message="form.errors.acceptance_criteria"
@@ -608,36 +819,48 @@ const formatDateTime = (value: string): string => {
</div> </div>
<div <div
class="border-border grid gap-3 rounded-xl border p-4" class="dark:bg-muted/25 grid gap-4 rounded-[1.5rem] border border-dashed border-[#adb3b5]/35 bg-[#f8f9fa]/80 p-5 dark:border-white/10"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<input <Checkbox
id="is_blocked" id="is_blocked"
v-model="form.is_blocked" :checked="form.is_blocked"
type="checkbox"
:disabled=" :disabled="
!canEditTicket || form.processing !canEditTicket || form.processing
" "
class="border-input text-primary focus:ring-ring size-4 rounded" @update:checked="
(
value:
| boolean
| 'indeterminate',
) =>
(form.is_blocked =
value === true)
"
/> />
<Label for="is_blocked"> <Label
for="is_blocked"
:class="fieldLabelClass"
>
Mark ticket as blocked Mark ticket as blocked
</Label> </Label>
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="blocked_comment"> <Label
for="blocked_comment"
:class="fieldLabelClass"
>
Blocker comment Blocker comment
</Label> </Label>
<textarea <Textarea
id="blocked_comment" id="blocked_comment"
v-model="form.blocked_comment" v-model="form.blocked_comment"
rows="3"
:disabled=" :disabled="
!canEditTicket || form.processing !canEditTicket || form.processing
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
placeholder="Describe what currently blocks progress on this ticket." placeholder="Describe what currently blocks progress on this ticket."
:class="textareaClass"
/> />
<InputError <InputError
:message="form.errors.blocked_comment" :message="form.errors.blocked_comment"
@@ -674,6 +897,7 @@ const formatDateTime = (value: string): string => {
> >
<Label <Label
:for="`field-${ticketFieldDefinition.id}`" :for="`field-${ticketFieldDefinition.id}`"
:class="fieldLabelClass"
> >
{{ ticketFieldDefinition.label }} {{ ticketFieldDefinition.label }}
</Label> </Label>
@@ -692,7 +916,7 @@ const formatDateTime = (value: string): string => {
!canEditTicket || !canEditTicket ||
form.processing form.processing
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60" :class="nativeSelectClass"
> >
<option value="">Unassigned</option> <option value="">Unassigned</option>
<option <option
@@ -717,6 +941,7 @@ const formatDateTime = (value: string): string => {
!canEditTicket || !canEditTicket ||
form.processing form.processing
" "
:class="inputClass"
/> />
<InputError <InputError
@@ -749,7 +974,7 @@ const formatDateTime = (value: string): string => {
</Card> </Card>
<div class="space-y-6"> <div class="space-y-6">
<Card class="h-fit"> <Card :class="frostedAsideCardClass">
<CardHeader <CardHeader
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
> >
@@ -804,7 +1029,7 @@ const formatDateTime = (value: string): string => {
:disabled=" :disabled="
branchCreationForm.processing branchCreationForm.processing
" "
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60" :class="nativeSelectClass"
> >
<option <option
v-for="repositoryConnection in linkedRepositoryConnections" v-for="repositoryConnection in linkedRepositoryConnections"
@@ -843,6 +1068,7 @@ const formatDateTime = (value: string): string => {
branchCreationForm.processing || branchCreationForm.processing ||
!selectedBranchRepositoryHasPersonalAccess !selectedBranchRepositoryHasPersonalAccess
" "
:class="inputClass"
/> />
<InputError <InputError
:message=" :message="
@@ -1041,7 +1267,7 @@ const formatDateTime = (value: string): string => {
</CardContent> </CardContent>
</Card> </Card>
<Card class="h-fit"> <Card :class="frostedAsideCardClass">
<CardHeader> <CardHeader>
<CardTitle>Linked tickets</CardTitle> <CardTitle>Linked tickets</CardTitle>
<CardDescription> <CardDescription>
@@ -1057,13 +1283,14 @@ const formatDateTime = (value: string): string => {
ticketLinkTypes.length > 0 && ticketLinkTypes.length > 0 &&
ticketLinkOptions.length > 0 ticketLinkOptions.length > 0
" "
v-bind=" :action="
ProjectTicketLinkController.store.form({ ProjectTicketLinkController.store.url({
project: project.id, project: project.id,
ticket: ticket.id, ticket: ticket.id,
}) })
" "
class="border-border grid gap-4 rounded-xl border border-dashed p-4" class="border-border grid gap-4 rounded-xl border border-dashed p-4"
method="post"
v-slot="{ errors, processing }" v-slot="{ errors, processing }"
> >
<div class="grid gap-2"> <div class="grid gap-2">
@@ -1073,7 +1300,7 @@ const formatDateTime = (value: string): string => {
<select <select
id="ticket_link_type_id" id="ticket_link_type_id"
name="ticket_link_type_id" name="ticket_link_type_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]" :class="nativeSelectClass"
> >
<option <option
v-for="ticketLinkType in ticketLinkTypes" v-for="ticketLinkType in ticketLinkTypes"
@@ -1095,7 +1322,7 @@ const formatDateTime = (value: string): string => {
<select <select
id="linked_ticket_id" id="linked_ticket_id"
name="linked_ticket_id" name="linked_ticket_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]" :class="nativeSelectClass"
> >
<option <option
v-for="ticketLinkOption in ticketLinkOptions" v-for="ticketLinkOption in ticketLinkOptions"
@@ -1164,8 +1391,8 @@ const formatDateTime = (value: string): string => {
<Form <Form
v-if="canEditTicket" v-if="canEditTicket"
v-bind=" :action="
ProjectTicketLinkController.destroy.form( ProjectTicketLinkController.destroy.url(
{ {
project: project.id, project: project.id,
ticket: ticket.id, ticket: ticket.id,
@@ -1174,6 +1401,7 @@ const formatDateTime = (value: string): string => {
) )
" "
class="mt-3 flex justify-end" class="mt-3 flex justify-end"
method="delete"
v-slot="{ processing: deleting }" v-slot="{ processing: deleting }"
> >
<Button <Button
@@ -1190,7 +1418,7 @@ const formatDateTime = (value: string): string => {
</CardContent> </CardContent>
</Card> </Card>
<Card class="h-fit"> <Card :class="frostedAsideCardClass">
<CardHeader> <CardHeader>
<CardTitle>Pull requests</CardTitle> <CardTitle>Pull requests</CardTitle>
<CardDescription> <CardDescription>
@@ -1319,7 +1547,7 @@ const formatDateTime = (value: string): string => {
</CardContent> </CardContent>
</Card> </Card>
<Card class="h-fit"> <Card :class="frostedAsideCardClass">
<CardHeader> <CardHeader>
<CardTitle>Child tickets</CardTitle> <CardTitle>Child tickets</CardTitle>
<CardDescription> <CardDescription>
@@ -1345,7 +1573,7 @@ const formatDateTime = (value: string): string => {
</CardContent> </CardContent>
</Card> </Card>
<Card class="h-fit"> <Card :class="frostedAsideCardClass">
<CardHeader> <CardHeader>
<CardTitle>Worklog</CardTitle> <CardTitle>Worklog</CardTitle>
<CardDescription> <CardDescription>
@@ -1402,6 +1630,108 @@ const formatDateTime = (value: string): string => {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<div class="space-y-6 2xl:sticky 2xl:top-24 2xl:self-start">
<Card :class="frostedAsideCardClass">
<CardHeader>
<CardTitle
class="font-display dark:text-foreground text-xl text-[#2d3335]"
>
Snapshot
</CardTitle>
<CardDescription>
Quick context for the ticket without scrolling
through the full detail form.
</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<div
class="dark:bg-muted/35 rounded-[1.25rem] bg-[#f1f4f5] px-4 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]"
>
<p
class="dark:text-muted-foreground text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]"
>
Assignee
</p>
<p
class="dark:text-foreground mt-1 text-sm font-medium text-[#2d3335]"
>
{{ selectedAssignee?.name ?? 'Unassigned' }}
</p>
</div>
<div
class="dark:bg-muted/25 rounded-[1.25rem] bg-[#f8f9fa] px-4 py-3"
>
<p
class="dark:text-muted-foreground text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]"
>
Author
</p>
<p
class="dark:text-foreground mt-1 text-sm font-medium text-[#2d3335]"
>
{{ selectedAuthor?.name ?? 'No author' }}
</p>
</div>
<div
class="grid gap-3 sm:grid-cols-2 2xl:grid-cols-1"
>
<div
class="dark:bg-muted/25 rounded-[1.25rem] bg-[#f8f9fa] px-4 py-3"
>
<p
class="dark:text-muted-foreground text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]"
>
Branches
</p>
<p
class="dark:text-foreground mt-1 text-lg font-semibold text-[#2d3335]"
>
{{ repositoryBranchCount }}
</p>
</div>
<div
class="dark:bg-muted/25 rounded-[1.25rem] bg-[#f8f9fa] px-4 py-3"
>
<p
class="dark:text-muted-foreground text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]"
>
Pull requests
</p>
<p
class="dark:text-foreground mt-1 text-lg font-semibold text-[#2d3335]"
>
{{ pullRequestCount }}
</p>
</div>
</div>
<div
class="dark:bg-muted/25 rounded-[1.25rem] bg-[#f8f9fa] px-4 py-3"
>
<p
class="dark:text-muted-foreground text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]"
>
Last updated
</p>
<p
class="dark:text-foreground mt-1 text-sm font-medium text-[#2d3335]"
>
{{ formatDateTime(ticket.updated_at) }}
</p>
</div>
</CardContent>
</Card>
<FeatureComingSoonPanel
feature="Comments & discussion"
description="The Stitch design already reserves this full rail for threaded comments, collaboration, and follow-up context. The visual shell is in place here until the feature is wired."
/>
</div>
</div> </div>
</div> </div>
</AppLayout> </AppLayout>
+584 -197
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController'; import { computed } from 'vue';
import Heading from '@/components/Heading.vue'; import TicketPriorityIcon from '@/components/TicketPriorityIcon.vue';
import TicketTypeIcon from '@/components/TicketTypeIcon.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -14,6 +15,14 @@ import {
} from '@/components/ui/card'; } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; 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 AppLayout from '@/layouts/AppLayout.vue';
import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board'; import { index as boardIndex } from '@/routes/projects/board';
@@ -24,11 +33,27 @@ import type { TicketStatusModel } from '@/types/models/ticket-status-model';
const props = defineProps<ProjectTicketCreatePageProps>(); 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 = const returnHref =
props.source === 'board' props.source === 'board'
? boardIndex(props.project.id) ? boardIndex(props.project.id)
: backlogIndex(props.project.id); : backlogIndex(props.project.id);
const returnLabel = props.source === 'board' ? 'Back to board' : 'Back to backlog'; const returnLabel =
props.source === 'board' ? 'Back to board' : 'Back to backlog';
const breadcrumbs: BreadcrumbItem[] = [ const breadcrumbs: BreadcrumbItem[] = [
{ {
@@ -45,9 +70,7 @@ const breadcrumbs: BreadcrumbItem[] = [
}, },
{ {
title: 'Create ticket', title: 'Create ticket',
href: ProjectTicketController.create({ href: `/projects/${props.project.id}/tickets/create?source=${props.source}`,
project: props.project.id,
}),
}, },
]; ];
@@ -56,6 +79,53 @@ const hasTicketMetadata =
props.ticketPriorities.length > 0 && props.ticketPriorities.length > 0 &&
props.ticketStatusOptions.length > 0; props.ticketStatusOptions.length > 0;
const form = useForm({
acceptance_criteria: '',
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 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.post(`/projects/${props.project.id}/tickets`, {
preserveScroll: true,
});
};
const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => { const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
if ( if (
ticketStatus.board_column === null || ticketStatus.board_column === null ||
@@ -76,226 +146,543 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`Create ${project.name} ticket`" /> <Head :title="`Create ${project.name} ticket`" />
<div class="flex flex-1 flex-col gap-6 p-4"> <div class="relative isolate flex flex-1 flex-col gap-8 px-4 py-6">
<div <div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between" 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%)]"
> />
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
title="Create ticket"
:description="`Add a ticket to ${project.name}.`"
/>
</div>
<div class="flex flex-wrap gap-3"> <section
<Button as-child variant="outline"> 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"
<Link :href="returnHref">{{ returnLabel }}</Link> >
</Button> <div
<Button as-child variant="outline"> class="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between"
<Link :href="show(project.id)">Back to project</Link> >
</Button> <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> </div>
</div> </section>
<div <div
v-if="status" v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100" 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 }} {{ status }}
</div> </div>
<Card v-if="hasTicketMetadata"> <div
<CardHeader> v-if="hasTicketMetadata"
<CardTitle>Ticket details</CardTitle> class="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_22rem]"
<CardDescription> >
The ticket key is assigned after creation. <Card :class="frostedCardClass">
</CardDescription> <CardHeader class="border-b border-[#adb3b5]/20 pb-6">
</CardHeader> <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> <CardContent class="p-6">
<Form <form class="grid gap-6" @submit.prevent="submit">
v-bind="ProjectTicketController.store.form(project.id)" <div class="grid gap-2">
class="grid gap-6" <Label for="title" :class="fieldLabelClass">
v-slot="{ errors, processing }" Title
> </Label>
<div class="grid gap-2"> <Input
<Label for="title">Title</Label> id="title"
<Input v-model="form.title"
id="title" required
name="title" autofocus
required :disabled="form.processing"
autofocus placeholder="Implement repository sync"
placeholder="Implement repository sync" :class="inputClass"
/> />
<InputError :message="errors.title" /> <InputError :message="form.errors.title" />
</div>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="description">Description</Label>
<textarea
id="description"
name="description"
rows="4"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
placeholder="Describe the expected behavior and relevant context."
/>
<InputError :message="errors.description" />
</div>
<div class="grid gap-2">
<Label for="acceptance_criteria">
Acceptance criteria
</Label>
<textarea
id="acceptance_criteria"
name="acceptance_criteria"
rows="4"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
placeholder="List the conditions that must be true before this ticket is done."
/>
<InputError
:message="errors.acceptance_criteria"
/>
</div>
</div> </div>
<div class="grid h-fit gap-6"> <div
<div class="grid gap-2"> class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]"
<Label for="ticket_type_id">Type</Label> >
<select <div class="grid gap-6">
id="ticket_type_id" <div class="grid gap-2">
name="ticket_type_id" <Label
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]" for="description"
> :class="fieldLabelClass"
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
> >
{{ ticketType.name }} Description
</option> </Label>
</select> <Textarea
<InputError id="description"
:message="errors.ticket_type_id" v-model="form.description"
/> :disabled="form.processing"
</div> placeholder="Describe the expected behavior, context, and why this work matters."
:class="textareaClass"
/>
<InputError
:message="form.errors.description"
/>
</div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="ticket_priority_id"> <Label
Priority for="acceptance_criteria"
</Label> :class="fieldLabelClass"
<select
id="ticket_priority_id"
name="ticket_priority_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
> >
{{ ticketPriority.name }} Acceptance criteria
</option> </Label>
</select> <Textarea
<InputError id="acceptance_criteria"
:message="errors.ticket_priority_id" v-model="form.acceptance_criteria"
/> :disabled="form.processing"
</div> placeholder="List the conditions that must be true before this ticket is done."
:class="textareaClass"
<div class="grid gap-2"> />
<Label for="ticket_status_id">Status</Label> <InputError
<select :message="
id="ticket_status_id" form.errors.acceptance_criteria
name="ticket_status_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option
v-for="ticketStatus in ticketStatusOptions"
:key="ticketStatus.id"
:value="ticketStatus.id"
:selected="
ticketStatus.id ===
defaultTicketStatusId
" "
> />
{{ ticketStatusLabel(ticketStatus) }} </div>
</option>
</select>
<InputError
:message="errors.ticket_status_id"
/>
</div>
<div class="grid gap-2">
<Label for="assignee_user_id">
Assignee
</Label>
<select
id="assignee_user_id"
name="assignee_user_id"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px]"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError
:message="errors.assignee_user_id"
/>
</div> </div>
<div <div
v-if="project.show_story_points" 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)]"
class="grid gap-2"
> >
<Label for="story_points"> <div class="grid gap-2">
Estimated story points <Label
</Label> for="ticket_type_id"
<Input :class="fieldLabelClass"
id="story_points" >
name="story_points" Type
type="number" </Label>
step="1" <Select
/> v-model="form.ticket_type_id"
<InputError :disabled="form.processing"
:message="errors.story_points" >
/> <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> </div>
</div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3 pt-2">
<Button type="submit" :disabled="processing"> <Button
Create ticket type="submit"
</Button> :disabled="form.processing"
<Button as-child variant="outline"> 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"
<Link :href="returnHref"> >
{{ returnLabel }} Create ticket
</Link> </Button>
</Button> <Button
</div> as-child
</Form> variant="outline"
</CardContent> :class="airyOutlineButtonClass"
</Card> >
<Link :href="returnHref">
{{ returnLabel }}
</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
<Card v-else> <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"> <CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground"> <p
class="dark:text-muted-foreground leading-7 text-[#5a6062]"
>
Ticket creation needs at least one type, one priority, Ticket creation needs at least one type, one priority,
and one status. and one status.
</p> </p>
<div> <div>
<Button as-child variant="outline"> <Button
as-child
variant="outline"
:class="airyOutlineButtonClass"
>
<Link :href="metadataSettings(project.id)"> <Link :href="metadataSettings(project.id)">
Open ticket settings Open ticket settings
</Link> </Link>