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 ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
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 TicketPriorityIcon from '@/components/TicketPriorityIcon.vue';
import TicketTreeList from '@/components/TicketTreeList.vue';
import TicketTypeIcon from '@/components/TicketTypeIcon.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -25,8 +27,17 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
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 { edit as editGitConnections } from '@/routes/git-connections/index';
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({
acceptance_criteria: props.ticket.acceptance_criteria ?? '',
assignee_user_id: props.ticket.assignee_user_id ?? '',
@@ -124,12 +154,56 @@ const form = useForm({
}),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_id ?? '',
ticket_priority_id: props.ticket.ticket_priority_id,
ticket_type_id: props.ticket.ticket_type_id,
ticket_priority_id: String(props.ticket.ticket_priority_id),
ticket_type_id: String(props.ticket.ticket_type_id),
title: props.ticket.title,
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 isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
@@ -271,8 +345,7 @@ watch(
branchNameValidation.value = {
is_valid: false,
message:
`${appName} could not validate this branch name right now.`,
message: `${appName} could not validate this branch name right now.`,
};
} finally {
if (!abortController.signal.aborted) {
@@ -341,65 +414,139 @@ const formatDateTime = (value: string): string => {
<AppLayout :breadcrumbs="breadcrumbs">
<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
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 class="flex flex-wrap items-center gap-2">
<Badge variant="outline" class="px-3 py-1">
{{ ticket.ticket_key }}
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_type.name }}
</Badge>
<Badge variant="secondary">
{{ ticket.ticket_priority.name }}
</Badge>
<Badge v-if="ticket.ticket_status" variant="outline">
{{ ticket.ticket_status.name }}
</Badge>
<Badge v-if="!canEditTicket" variant="outline">
Read only
</Badge>
<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"
>
{{ ticket.ticket_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 }}
</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>
<Heading
:title="ticket.title"
description="Review ticket details, update tracked fields, and inspect the change timeline."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)"
>Open backlog</Link
<div class="flex flex-wrap gap-3">
<Button
as-child
variant="outline"
:class="airyOutlineButtonClass"
>
</Button>
<Link :href="backlogIndex(project.id)">
Open backlog
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
</Button>
<Button
as-child
variant="outline"
:class="airyOutlineButtonClass"
>
<Link :href="boardIndex(project.id)"
>Open board</Link
>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
<Button
as-child
variant="outline"
:class="airyOutlineButtonClass"
>
<Link :href="show(project.id)"
>Back to project</Link
>
</Button>
</div>
</div>
</div>
</section>
<div
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 }}
</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>
<CardTitle>Ticket details</CardTitle>
<CardDescription>
<CardTitle
class="font-display dark:text-foreground text-2xl text-[#2d3335]"
>
Ticket details
</CardTitle>
<CardDescription class="max-w-2xl leading-6">
{{
canEditTicket
? 'Tracked ticket changes create worklog entries automatically.'
@@ -411,61 +558,104 @@ const formatDateTime = (value: string): string => {
<CardContent>
<form class="grid gap-6" @submit.prevent="submit">
<div class="grid gap-2">
<Label for="title">Title</Label>
<Label for="title" :class="fieldLabelClass">
Title
</Label>
<Input
id="title"
v-model="form.title"
:disabled="
!canEditTicket || form.processing
"
:class="inputClass"
/>
<InputError :message="form.errors.title" />
</div>
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
<Label
for="ticket_type_id"
:class="fieldLabelClass"
>
Type
</Label>
<Select
v-model="form.ticket_type_id"
:disabled="
!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
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
<SelectTrigger
id="ticket_type_id"
:class="selectTriggerClass"
>
{{ ticketType.name }}
</option>
</select>
<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">
<Label
for="ticket_priority_id"
:class="fieldLabelClass"
>
Priority
</Label>
<select
id="ticket_priority_id"
<Select
v-model="form.ticket_priority_id"
:disabled="
!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
v-for="ticketPriority in ticketPriorities"
:key="ticketPriority.id"
:value="ticketPriority.id"
<SelectTrigger
id="ticket_priority_id"
:class="selectTriggerClass"
>
{{ ticketPriority.name }}
</option>
</select>
<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
@@ -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">
<Label for="assignee_user_id">
<Label
for="assignee_user_id"
:class="fieldLabelClass"
>
Assignee
</Label>
<select
@@ -485,7 +678,7 @@ const formatDateTime = (value: string): string => {
:disabled="
!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
@@ -502,14 +695,19 @@ const formatDateTime = (value: string): string => {
</div>
<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
id="author_user_id"
v-model="form.author_user_id"
:disabled="
!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
@@ -530,7 +728,10 @@ const formatDateTime = (value: string): string => {
class="grid gap-2"
v-if="project.show_story_points"
>
<Label for="story_points">
<Label
for="story_points"
:class="fieldLabelClass"
>
Estimated story points
</Label>
<Input
@@ -539,6 +740,7 @@ const formatDateTime = (value: string): string => {
v-model="form.story_points"
type="number"
step="1"
:class="inputClass"
/>
<InputError
:message="form.errors.story_points"
@@ -546,7 +748,10 @@ const formatDateTime = (value: string): string => {
</div>
<div class="grid gap-2">
<Label for="parent_ticket_id">
<Label
for="parent_ticket_id"
:class="fieldLabelClass"
>
Parent ticket
</Label>
<select
@@ -555,7 +760,7 @@ const formatDateTime = (value: string): string => {
:disabled="
!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
@@ -574,15 +779,19 @@ const formatDateTime = (value: string): string => {
</div>
<div class="grid gap-2">
<Label for="description">Description</Label>
<textarea
<Label
for="description"
:class="fieldLabelClass"
>
Description
</Label>
<Textarea
id="description"
v-model="form.description"
rows="5"
:disabled="
!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
:message="form.errors.description"
@@ -590,17 +799,19 @@ const formatDateTime = (value: string): string => {
</div>
<div class="grid gap-2">
<Label for="acceptance_criteria">
<Label
for="acceptance_criteria"
:class="fieldLabelClass"
>
Acceptance criteria
</Label>
<textarea
<Textarea
id="acceptance_criteria"
v-model="form.acceptance_criteria"
rows="5"
:disabled="
!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
:message="form.errors.acceptance_criteria"
@@ -608,36 +819,48 @@ const formatDateTime = (value: string): string => {
</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">
<input
<Checkbox
id="is_blocked"
v-model="form.is_blocked"
type="checkbox"
:checked="form.is_blocked"
:disabled="
!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
</Label>
</div>
<div class="grid gap-2">
<Label for="blocked_comment">
<Label
for="blocked_comment"
:class="fieldLabelClass"
>
Blocker comment
</Label>
<textarea
<Textarea
id="blocked_comment"
v-model="form.blocked_comment"
rows="3"
:disabled="
!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."
:class="textareaClass"
/>
<InputError
:message="form.errors.blocked_comment"
@@ -674,6 +897,7 @@ const formatDateTime = (value: string): string => {
>
<Label
:for="`field-${ticketFieldDefinition.id}`"
:class="fieldLabelClass"
>
{{ ticketFieldDefinition.label }}
</Label>
@@ -692,7 +916,7 @@ const formatDateTime = (value: string): string => {
!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
@@ -717,6 +941,7 @@ const formatDateTime = (value: string): string => {
!canEditTicket ||
form.processing
"
:class="inputClass"
/>
<InputError
@@ -749,7 +974,7 @@ const formatDateTime = (value: string): string => {
</Card>
<div class="space-y-6">
<Card class="h-fit">
<Card :class="frostedAsideCardClass">
<CardHeader
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="
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
v-for="repositoryConnection in linkedRepositoryConnections"
@@ -843,6 +1068,7 @@ const formatDateTime = (value: string): string => {
branchCreationForm.processing ||
!selectedBranchRepositoryHasPersonalAccess
"
:class="inputClass"
/>
<InputError
:message="
@@ -1041,7 +1267,7 @@ const formatDateTime = (value: string): string => {
</CardContent>
</Card>
<Card class="h-fit">
<Card :class="frostedAsideCardClass">
<CardHeader>
<CardTitle>Linked tickets</CardTitle>
<CardDescription>
@@ -1057,13 +1283,14 @@ const formatDateTime = (value: string): string => {
ticketLinkTypes.length > 0 &&
ticketLinkOptions.length > 0
"
v-bind="
ProjectTicketLinkController.store.form({
:action="
ProjectTicketLinkController.store.url({
project: project.id,
ticket: ticket.id,
})
"
class="border-border grid gap-4 rounded-xl border border-dashed p-4"
method="post"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
@@ -1073,7 +1300,7 @@ const formatDateTime = (value: string): string => {
<select
id="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
v-for="ticketLinkType in ticketLinkTypes"
@@ -1095,7 +1322,7 @@ const formatDateTime = (value: string): string => {
<select
id="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
v-for="ticketLinkOption in ticketLinkOptions"
@@ -1164,8 +1391,8 @@ const formatDateTime = (value: string): string => {
<Form
v-if="canEditTicket"
v-bind="
ProjectTicketLinkController.destroy.form(
:action="
ProjectTicketLinkController.destroy.url(
{
project: project.id,
ticket: ticket.id,
@@ -1174,6 +1401,7 @@ const formatDateTime = (value: string): string => {
)
"
class="mt-3 flex justify-end"
method="delete"
v-slot="{ processing: deleting }"
>
<Button
@@ -1190,7 +1418,7 @@ const formatDateTime = (value: string): string => {
</CardContent>
</Card>
<Card class="h-fit">
<Card :class="frostedAsideCardClass">
<CardHeader>
<CardTitle>Pull requests</CardTitle>
<CardDescription>
@@ -1319,7 +1547,7 @@ const formatDateTime = (value: string): string => {
</CardContent>
</Card>
<Card class="h-fit">
<Card :class="frostedAsideCardClass">
<CardHeader>
<CardTitle>Child tickets</CardTitle>
<CardDescription>
@@ -1345,7 +1573,7 @@ const formatDateTime = (value: string): string => {
</CardContent>
</Card>
<Card class="h-fit">
<Card :class="frostedAsideCardClass">
<CardHeader>
<CardTitle>Worklog</CardTitle>
<CardDescription>
@@ -1402,6 +1630,108 @@ const formatDateTime = (value: string): string => {
</CardContent>
</Card>
</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>
</AppLayout>
+584 -197
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import Heading from '@/components/Heading.vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
import { computed } 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';
@@ -14,6 +15,14 @@ import {
} 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 { index as backlogIndex } from '@/routes/projects/backlog';
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 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 returnLabel =
props.source === 'board' ? 'Back to board' : 'Back to backlog';
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -45,9 +70,7 @@ const breadcrumbs: BreadcrumbItem[] = [
},
{
title: 'Create ticket',
href: ProjectTicketController.create({
project: props.project.id,
}),
href: `/projects/${props.project.id}/tickets/create?source=${props.source}`,
},
];
@@ -56,6 +79,53 @@ const hasTicketMetadata =
props.ticketPriorities.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 => {
if (
ticketStatus.board_column === null ||
@@ -76,226 +146,543 @@ const ticketStatusLabel = (ticketStatus: TicketStatusModel): string => {
<AppLayout :breadcrumbs="breadcrumbs">
<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
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<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>
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="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="returnHref">{{ returnLabel }}</Link>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
</Button>
<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>
</div>
</section>
<div
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 }}
</div>
<Card v-if="hasTicketMetadata">
<CardHeader>
<CardTitle>Ticket details</CardTitle>
<CardDescription>
The ticket key is assigned after creation.
</CardDescription>
</CardHeader>
<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>
<Form
v-bind="ProjectTicketController.store.form(project.id)"
class="grid gap-6"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="title">Title</Label>
<Input
id="title"
name="title"
required
autofocus
placeholder="Implement repository sync"
/>
<InputError :message="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>
<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 h-fit gap-6">
<div class="grid gap-2">
<Label for="ticket_type_id">Type</Label>
<select
id="ticket_type_id"
name="ticket_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]"
>
<option
v-for="ticketType in ticketTypes"
:key="ticketType.id"
:value="ticketType.id"
<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"
>
{{ ticketType.name }}
</option>
</select>
<InputError
:message="errors.ticket_type_id"
/>
</div>
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-2">
<Label for="ticket_priority_id">
Priority
</Label>
<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"
<div class="grid gap-2">
<Label
for="acceptance_criteria"
:class="fieldLabelClass"
>
{{ ticketPriority.name }}
</option>
</select>
<InputError
:message="errors.ticket_priority_id"
/>
</div>
<div class="grid gap-2">
<Label for="ticket_status_id">Status</Label>
<select
id="ticket_status_id"
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
Acceptance criteria
</Label>
<Textarea
id="acceptance_criteria"
v-model="form.acceptance_criteria"
:disabled="form.processing"
placeholder="List the conditions that must be true before this ticket is done."
:class="textareaClass"
/>
<InputError
:message="
form.errors.acceptance_criteria
"
>
{{ ticketStatusLabel(ticketStatus) }}
</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
v-if="project.show_story_points"
class="grid gap-2"
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)]"
>
<Label for="story_points">
Estimated story points
</Label>
<Input
id="story_points"
name="story_points"
type="number"
step="1"
/>
<InputError
:message="errors.story_points"
/>
<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>
<div class="flex flex-wrap items-center gap-3">
<Button type="submit" :disabled="processing">
Create ticket
</Button>
<Button as-child variant="outline">
<Link :href="returnHref">
{{ returnLabel }}
</Link>
</Button>
</div>
</Form>
</CardContent>
</Card>
<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>
<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">
<p class="text-muted-foreground">
<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">
<Button
as-child
variant="outline"
:class="airyOutlineButtonClass"
>
<Link :href="metadataSettings(project.id)">
Open ticket settings
</Link>