Merge branch 'codex/stitch-ticket-pages' into staging

This commit is contained in:
2026-04-24 18:57:48 +02:00
96 changed files with 6728 additions and 1713 deletions
+58 -55
View File
@@ -9,10 +9,13 @@
@theme inline {
--font-sans:
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
Inter, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-display:
Manrope, ui-sans-serif, system-ui, sans-serif;
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
@@ -83,83 +86,83 @@
body,
html {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
'Inter', ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(0 0% 3.9%);
--background: hsl(120 10% 98%);
--foreground: hsl(120 5% 12%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(0 0% 3.9%);
--card-foreground: hsl(120 5% 12%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(0 0% 3.9%);
--primary: hsl(0 0% 9%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(0 0% 92.1%);
--secondary-foreground: hsl(0 0% 9%);
--muted: hsl(0 0% 96.1%);
--muted-foreground: hsl(0 0% 45.1%);
--accent: hsl(0 0% 96.1%);
--accent-foreground: hsl(0 0% 9%);
--popover-foreground: hsl(120 5% 12%);
--primary: hsl(107 80% 24%);
--primary-foreground: hsl(107 30% 97%);
--secondary: hsl(107 8% 91%);
--secondary-foreground: hsl(107 10% 18%);
--muted: hsl(107 8% 95%);
--muted-foreground: hsl(107 5% 42%);
--accent: hsl(107 10% 93%);
--accent-foreground: hsl(107 10% 18%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 92.8%);
--input: hsl(0 0% 89.8%);
--ring: hsl(0 0% 3.9%);
--chart-1: hsl(12 76% 61%);
--border: hsl(107 8% 90%);
--input: hsl(107 8% 87%);
--ring: hsl(107 80% 24%);
--chart-1: hsl(107 80% 30%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.5rem;
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(0 0% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(0 0% 94%);
--sidebar-accent-foreground: hsl(0 0% 30%);
--sidebar-border: hsl(0 0% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(0 0% 98%);
--radius: 0.625rem;
--sidebar-background: hsl(120 10% 97%);
--sidebar-foreground: hsl(120 5% 26%);
--sidebar-primary: hsl(107 80% 24%);
--sidebar-primary-foreground: hsl(107 30% 97%);
--sidebar-accent: hsl(107 10% 93%);
--sidebar-accent-foreground: hsl(107 10% 25%);
--sidebar-border: hsl(107 8% 90%);
--sidebar-ring: hsl(107 80% 24%);
--sidebar: hsl(120 10% 97%);
}
.dark {
--background: hsl(0 0% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(0 0% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(0 0% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(0 0% 9%);
--secondary: hsl(0 0% 14.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(0 0% 16.08%);
--muted-foreground: hsl(0 0% 63.9%);
--accent: hsl(0 0% 14.9%);
--accent-foreground: hsl(0 0% 98%);
--background: hsl(120 5% 6%);
--foreground: hsl(120 5% 95%);
--card: hsl(120 5% 8%);
--card-foreground: hsl(120 5% 95%);
--popover: hsl(120 5% 8%);
--popover-foreground: hsl(120 5% 95%);
--primary: hsl(107 70% 55%);
--primary-foreground: hsl(107 80% 8%);
--secondary: hsl(107 5% 16%);
--secondary-foreground: hsl(107 5% 90%);
--muted: hsl(107 5% 14%);
--muted-foreground: hsl(107 5% 60%);
--accent: hsl(107 8% 16%);
--accent-foreground: hsl(107 5% 90%);
--destructive: hsl(0 84% 60%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(0 0% 14.9%);
--input: hsl(0 0% 14.9%);
--ring: hsl(0 0% 83.1%);
--chart-1: hsl(220 70% 50%);
--border: hsl(107 5% 16%);
--input: hsl(107 5% 16%);
--ring: hsl(107 70% 55%);
--chart-1: hsl(107 60% 45%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(0 0% 7%);
--sidebar-foreground: hsl(0 0% 95.9%);
--sidebar-primary: hsl(360, 100%, 100%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(0 0% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(0 0% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
--sidebar-background: hsl(120 5% 7%);
--sidebar-foreground: hsl(120 5% 92%);
--sidebar-primary: hsl(107 70% 55%);
--sidebar-primary-foreground: hsl(107 80% 8%);
--sidebar-accent: hsl(107 8% 16%);
--sidebar-accent-foreground: hsl(107 5% 88%);
--sidebar-border: hsl(107 5% 16%);
--sidebar-ring: hsl(107 70% 55%);
--sidebar: hsl(120 5% 7%);
}
@layer base {
+53 -94
View File
@@ -1,6 +1,13 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-vue-next';
import {
Folder,
LayoutGrid,
Menu,
Search,
Shield,
Users,
} from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import AppLogoIcon from '@/components/AppLogoIcon.vue';
@@ -25,17 +32,13 @@ import {
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { getInitials } from '@/composables/useInitials';
import { toUrl } from '@/lib/utils';
import { dashboard } from '@/routes/index';
import { index as projectsIndex } from '@/routes/projects';
import { index as adminUsers } from '@/routes/admin/users';
import { index as teamsIndex } from '@/routes/teams';
import type { BreadcrumbItem, NavItem } from '@/types';
type Props = {
@@ -53,31 +56,43 @@ const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
const activeItemStyles =
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
const mainNavItems: NavItem[] = [
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
];
const mainNavItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
{
title: 'Projects',
href: projectsIndex(),
icon: Folder,
},
];
const rightNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: Folder,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
];
if (page.props.auth.permissions.globalAdmin) {
items.push({
title: 'Users',
href: adminUsers(),
icon: Shield,
});
}
if (page.props.auth.permissions.canManageTeams) {
items.push({
title: 'Teams',
href: teamsIndex(),
icon: Users,
});
}
return items;
});
</script>
<template>
<div>
<div class="border-sidebar-border/80 border-b">
<div class="bg-background/80 sticky top-0 z-50 backdrop-blur-md">
<div class="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
<!-- Mobile Menu -->
<div class="lg:hidden">
@@ -101,7 +116,7 @@ const rightNavItems: NavItem[] = [
/>
</SheetHeader>
<div
class="flex h-full flex-1 flex-col justify-between space-y-4 py-6"
class="flex h-full flex-1 flex-col space-y-4 py-6"
>
<nav class="-mx-3 space-y-1">
<Link
@@ -124,23 +139,6 @@ const rightNavItems: NavItem[] = [
{{ item.title }}
</Link>
</nav>
<div class="flex flex-col space-y-4">
<a
v-for="item in rightNavItems"
:key="item.title"
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
class="flex items-center space-x-2 text-sm font-medium"
>
<component
v-if="item.icon"
:is="item.icon"
class="h-5 w-5"
/>
<span>{{ item.title }}</span>
</a>
</div>
</div>
</SheetContent>
</Sheet>
@@ -189,54 +187,15 @@ const rightNavItems: NavItem[] = [
</div>
<div class="ml-auto flex items-center space-x-2">
<div class="relative flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
class="group h-9 w-9 cursor-pointer"
>
<Search
class="size-5 opacity-80 group-hover:opacity-100"
/>
</Button>
<div class="hidden space-x-1 lg:flex">
<template
v-for="item in rightNavItems"
:key="item.title"
>
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon"
as-child
class="group h-9 w-9 cursor-pointer"
>
<a
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
>
<span class="sr-only">{{
item.title
}}</span>
<component
:is="item.icon"
class="size-5 opacity-80 group-hover:opacity-100"
/>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ item.title }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>
</div>
</div>
<Button
variant="ghost"
size="icon"
class="group h-9 w-9 cursor-pointer"
>
<Search
class="size-5 opacity-80 group-hover:opacity-100"
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger :as-child="true">
@@ -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,494 @@
<script setup lang="ts">
import { Link, useForm, usePage } from '@inertiajs/vue3';
import { CheckCircle2, GitBranchPlus, XCircle } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import ProjectTicketBranchController from '@/actions/App/Http/Controllers/ProjectTicketBranchController';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { edit as editGitConnections } from '@/routes/git-connections/index';
import {
frostedAsideCardClass,
inputClass,
nativeSelectClass,
} from '@/lib/ticket-styles';
import type { BranchNameValidationModel } from '@/types/models/branch-name-validation-model';
import type { RepositoryConnectionModel } from '@/types/models/repository-connection-model';
import type { TicketRepositoryBranchModel } from '@/types/models/ticket-repository-branch-model';
const props = defineProps<{
canEditTicket: boolean;
projectId: number;
repositoryBranches: TicketRepositoryBranchModel[];
suggestedBranchName: string;
ticketId: number;
}>();
const page = usePage();
const appName = String(page.props.name);
const visibleRepositoryBranches = computed(() =>
props.repositoryBranches.filter(
(repositoryBranch) =>
repositoryBranch.branches.length > 0 ||
repositoryBranch.error !== null ||
!repositoryBranch.repository_connection.has_personal_access,
),
);
const linkedRepositoryConnections = computed<RepositoryConnectionModel[]>(() =>
props.repositoryBranches.map(
(repositoryBranch) => repositoryBranch.repository_connection,
),
);
const canCreateBranches = computed(
() => props.canEditTicket && linkedRepositoryConnections.value.length > 0,
);
const defaultBranchRepositoryConnectionId = computed(() =>
String(
linkedRepositoryConnections.value.find(
(repositoryConnection) => repositoryConnection.has_personal_access,
)?.id ??
linkedRepositoryConnections.value[0]?.id ??
'',
),
);
const isCreateBranchDialogOpen = ref(false);
const isCheckingBranchName = ref(false);
const branchNameValidation = ref<BranchNameValidationModel | null>(null);
const branchCreationForm = useForm<{
branch_name: string;
repository_connection_id: string;
}>({
branch_name: props.suggestedBranchName,
repository_connection_id: defaultBranchRepositoryConnectionId.value,
});
const selectedBranchRepositoryConnection = computed(
(): RepositoryConnectionModel | null =>
linkedRepositoryConnections.value.find(
(repositoryConnection) =>
String(repositoryConnection.id) ===
branchCreationForm.repository_connection_id,
) ?? null,
);
const selectedBranchRepositoryHasPersonalAccess = computed(
() =>
selectedBranchRepositoryConnection.value?.has_personal_access ?? false,
);
const initializeBranchCreationForm = (): void => {
branchCreationForm.clearErrors();
branchCreationForm.branch_name = props.suggestedBranchName;
branchCreationForm.repository_connection_id =
defaultBranchRepositoryConnectionId.value;
branchNameValidation.value = null;
isCheckingBranchName.value = false;
};
watch(
() => isCreateBranchDialogOpen.value,
(isOpen) => {
if (!isOpen) {
branchCreationForm.clearErrors();
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
initializeBranchCreationForm();
},
);
watch(
[
() => isCreateBranchDialogOpen.value,
() => branchCreationForm.repository_connection_id,
() => branchCreationForm.branch_name,
],
(
[isOpen, repositoryConnectionId, branchName],
_previousValues,
onCleanup,
) => {
if (!isOpen) {
return;
}
const repositoryConnection = selectedBranchRepositoryConnection.value;
if (repositoryConnection === null || repositoryConnectionId === '') {
branchNameValidation.value = null;
isCheckingBranchName.value = false;
return;
}
if (!repositoryConnection.has_personal_access) {
branchNameValidation.value = {
is_valid: false,
message:
'Connect a matching Git connection in your user settings first.',
};
isCheckingBranchName.value = false;
return;
}
if (branchName.trim() === '') {
branchNameValidation.value = {
is_valid: false,
message: 'Branch name is required.',
};
isCheckingBranchName.value = false;
return;
}
const abortController = new AbortController();
const timeoutId = window.setTimeout(async () => {
isCheckingBranchName.value = true;
try {
const response = await fetch(
ProjectTicketBranchController.validate.url(
{
project: props.projectId,
ticket: props.ticketId,
},
{
query: {
branch_name: branchName,
repository_connection_id:
repositoryConnectionId,
},
},
),
{
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
method: 'GET',
signal: abortController.signal,
},
);
if (!response.ok) {
throw new Error('Branch validation failed.');
}
branchNameValidation.value =
(await response.json()) as BranchNameValidationModel;
} catch (error) {
if (
error instanceof DOMException &&
error.name === 'AbortError'
) {
return;
}
branchNameValidation.value = {
is_valid: false,
message: `${appName} could not validate this branch name right now.`,
};
} finally {
if (!abortController.signal.aborted) {
isCheckingBranchName.value = false;
}
}
}, 300);
onCleanup(() => {
abortController.abort();
window.clearTimeout(timeoutId);
isCheckingBranchName.value = false;
});
},
{ immediate: true },
);
const submitBranchCreation = (): void => {
if (
!canCreateBranches.value ||
!selectedBranchRepositoryHasPersonalAccess.value ||
branchNameValidation.value?.is_valid !== true
) {
return;
}
branchCreationForm.submit(
ProjectTicketBranchController.store({
project: props.projectId,
ticket: props.ticketId,
}),
{
onSuccess: () => {
isCreateBranchDialogOpen.value = false;
},
preserveScroll: true,
},
);
};
</script>
<template>
<Card :class="frostedAsideCardClass">
<CardHeader
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-1.5">
<CardTitle>Branches</CardTitle>
<CardDescription>
Matching branches are discovered from linked repositories
when the branch name starts with this ticket key.
</CardDescription>
</div>
<Dialog v-model:open="isCreateBranchDialogOpen">
<Button
v-if="canCreateBranches"
type="button"
variant="outline"
class="gap-2"
@click="isCreateBranchDialogOpen = true"
>
<GitBranchPlus class="size-4" />
Create branch
</Button>
<DialogContent class="sm:max-w-lg">
<DialogHeader class="space-y-3">
<DialogTitle>Create branch from ticket</DialogTitle>
<DialogDescription>
Pick a linked repository, confirm the suggested
branch name, and create it directly in Gitea.
</DialogDescription>
</DialogHeader>
<form
class="grid gap-4"
@submit.prevent="submitBranchCreation"
>
<div class="grid gap-2">
<Label for="repository_connection_id">
Repository
</Label>
<select
id="repository_connection_id"
v-model="
branchCreationForm.repository_connection_id
"
:disabled="branchCreationForm.processing"
:class="nativeSelectClass"
>
<option
v-for="repositoryConnection in linkedRepositoryConnections"
:key="repositoryConnection.id"
:value="String(repositoryConnection.id)"
>
{{
`${repositoryConnection.repository_full_name}${repositoryConnection.has_personal_access ? '' : ' (connect personal Git access)'}`
}}
</option>
</select>
<InputError
:message="
branchCreationForm.errors
.repository_connection_id
"
/>
</div>
<div class="grid gap-2">
<Label for="branch_name">Branch name</Label>
<Input
id="branch_name"
v-model="branchCreationForm.branch_name"
:disabled="
branchCreationForm.processing ||
!selectedBranchRepositoryHasPersonalAccess
"
:class="inputClass"
/>
<InputError
:message="branchCreationForm.errors.branch_name"
/>
</div>
<div
class="rounded-xl border px-4 py-3"
:class="
branchNameValidation?.is_valid
? 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100'
: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100'
"
>
<div class="flex items-start gap-3">
<CheckCircle2
v-if="branchNameValidation?.is_valid"
class="mt-0.5 size-4 shrink-0"
/>
<XCircle
v-else-if="branchNameValidation !== null"
class="mt-0.5 size-4 shrink-0"
/>
<p class="text-sm">
{{
isCheckingBranchName
? 'Checking whether this branch name is valid and still available...'
: (branchNameValidation?.message ??
'Select a linked repository to validate the branch name.')
}}
</p>
</div>
</div>
<div
v-if="
!selectedBranchRepositoryHasPersonalAccess &&
selectedBranchRepositoryConnection !== null
"
class="border-border rounded-xl border border-dashed px-4 py-4"
>
<p class="text-muted-foreground text-sm">
You can only create branches in repositories
that match one of your personal Git connections.
</p>
<Button
as-child
variant="outline"
size="sm"
class="mt-3"
>
<Link :href="editGitConnections()">
Open Git connections
</Link>
</Button>
</div>
<DialogFooter class="gap-2">
<Button
type="button"
variant="secondary"
@click="isCreateBranchDialogOpen = false"
>
Cancel
</Button>
<Button
type="submit"
:disabled="
branchCreationForm.processing ||
isCheckingBranchName ||
branchNameValidation?.is_valid !== true
"
>
Create now
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="visibleRepositoryBranches.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No matching branches were found across the linked repositories
you can access.
</div>
<div v-else class="space-y-3">
<div
v-for="repositoryBranch in visibleRepositoryBranches"
:key="repositoryBranch.repository_connection.id"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryBranch.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryBranch.repository_connection
.repository_full_name
}}
</span>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{ repositoryBranch.repository_connection.base_url }}
</p>
<div
v-if="
!repositoryBranch.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p class="text-muted-foreground text-sm">
Connect a matching Git account in your user settings
to inspect branches for this repository.
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">
Open Git connections
</Link>
</Button>
</div>
<div
v-else-if="repositoryBranch.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryBranch.error }}
</div>
<div v-else class="mt-3 flex flex-wrap gap-2">
<Badge
v-for="branch in repositoryBranch.branches"
:key="branch.name"
variant="outline"
class="px-3 py-1"
>
{{ branch.name }}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import TicketTreeList from '@/components/TicketTreeList.vue';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { frostedAsideCardClass } from '@/lib/ticket-styles';
import type { TicketTreeNodeModel } from '@/types/models/ticket-tree-node-model';
defineProps<{
childTicketTree: TicketTreeNodeModel[];
projectId: number;
}>();
</script>
<template>
<Card :class="frostedAsideCardClass">
<CardHeader>
<CardTitle>Child tickets</CardTitle>
<CardDescription>
Parent tickets expose their full child tree here, including
nested parent chains.
</CardDescription>
</CardHeader>
<CardContent>
<div
v-if="childTicketTree.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No child tickets are assigned to this ticket yet.
</div>
<TicketTreeList
v-else
:nodes="childTicketTree"
:project-id="projectId"
/>
</CardContent>
</Card>
</template>
@@ -0,0 +1,417 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import InputError from '@/components/InputError.vue';
import TicketPriorityIcon from '@/components/TicketPriorityIcon.vue';
import TicketTypeIcon from '@/components/TicketTypeIcon.vue';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
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 {
fieldLabelClass,
frostedCardClass,
inputClass,
nativeSelectClass,
selectContentClass,
selectTriggerClass,
textareaClass,
} from '@/lib/ticket-styles';
import type { ProjectModel } from '@/types/models/project-model';
import type { TicketFieldDefinitionModel } from '@/types/models/ticket-field-definition-model';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketPriorityModel } from '@/types/models/ticket-priority-model';
import type { TicketReferenceModel } from '@/types/models/ticket-reference-model';
import type { TicketTypeModel } from '@/types/models/ticket-type-model';
import type { UserModel } from '@/types/models/user-model';
const props = defineProps<{
canEditAuthor: boolean;
canEditTicket: boolean;
canViewAuthor: boolean;
parentTicketOptions: TicketReferenceModel[];
project: ProjectModel;
ticket: TicketModel;
ticketFieldDefinitions: TicketFieldDefinitionModel[];
ticketPriorities: TicketPriorityModel[];
ticketTypes: TicketTypeModel[];
users: UserModel[];
}>();
const form = useForm({
acceptance_criteria: props.ticket.acceptance_criteria ?? '',
assignee_user_id: props.ticket.assignee_user_id ?? '',
author_user_id: props.ticket.author_user_id ?? '',
blocked_comment: props.ticket.blocked_comment ?? '',
description: props.ticket.description ?? '',
field_values: props.ticketFieldDefinitions.map((ticketFieldDefinition) => {
const currentFieldValue = props.ticket.field_values?.find(
(fieldValue) =>
fieldValue.ticket_field_definition_id ===
ticketFieldDefinition.id,
);
return {
ticket_field_definition_id: ticketFieldDefinition.id,
value_number: currentFieldValue?.value_number ?? '',
value_user_id: currentFieldValue?.value_user_id ?? '',
};
}),
is_blocked: props.ticket.is_blocked,
parent_ticket_id: props.ticket.parent_ticket_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 submit = (): void => {
if (!props.canEditTicket) {
return;
}
form.submit(
ProjectTicketController.update({
project: props.project.id,
ticket: props.ticket.id,
}),
{
preserveScroll: true,
},
);
};
</script>
<template>
<Card :class="frostedCardClass">
<CardHeader>
<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.'
: 'You can review the tracked fields, but editing requires project edit permission.'
}}
</CardDescription>
</CardHeader>
<CardContent>
<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"
: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" :class="fieldLabelClass">
Type
</Label>
<Select
v-model="form.ticket_type_id"
:disabled="!canEditTicket || 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="!canEditTicket || 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>
<div class="grid gap-2 md:grid-cols-2 md:gap-6">
<div class="grid gap-2">
<Label for="assignee_user_id" :class="fieldLabelClass">
Assignee
</Label>
<select
id="assignee_user_id"
v-model="form.assignee_user_id"
:disabled="!canEditTicket || form.processing"
:class="nativeSelectClass"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError :message="form.errors.assignee_user_id" />
</div>
<div v-if="canViewAuthor" class="grid gap-2">
<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="nativeSelectClass"
>
<option value="">No author</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<InputError :message="form.errors.author_user_id" />
</div>
</div>
<div v-if="project.show_story_points" class="grid gap-2">
<Label for="story_points" :class="fieldLabelClass">
Estimated story points
</Label>
<Input
id="story_points"
name="story_points"
v-model="form.story_points"
type="number"
step="1"
:class="inputClass"
/>
<InputError :message="form.errors.story_points" />
</div>
<div class="grid gap-2">
<Label for="parent_ticket_id" :class="fieldLabelClass">
Parent ticket
</Label>
<select
id="parent_ticket_id"
v-model="form.parent_ticket_id"
:disabled="!canEditTicket || form.processing"
:class="nativeSelectClass"
>
<option value="">No parent ticket</option>
<option
v-for="parentTicketOption in parentTicketOptions"
:key="parentTicketOption.id"
:value="parentTicketOption.id"
>
{{
`${parentTicketOption.ticket_key} - ${parentTicketOption.title}`
}}
</option>
</select>
<InputError :message="form.errors.parent_ticket_id" />
</div>
<div class="grid gap-2">
<Label for="description" :class="fieldLabelClass">
Description
</Label>
<Textarea
id="description"
v-model="form.description"
:disabled="!canEditTicket || form.processing"
:class="textareaClass"
/>
<InputError :message="form.errors.description" />
</div>
<div class="grid gap-2">
<Label for="acceptance_criteria" :class="fieldLabelClass">
Acceptance criteria
</Label>
<Textarea
id="acceptance_criteria"
v-model="form.acceptance_criteria"
:disabled="!canEditTicket || form.processing"
:class="textareaClass"
/>
<InputError :message="form.errors.acceptance_criteria" />
</div>
<div
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">
<Checkbox
id="is_blocked"
:checked="form.is_blocked"
:disabled="!canEditTicket || form.processing"
@update:checked="
(value: boolean | 'indeterminate') =>
(form.is_blocked = value === true)
"
/>
<Label for="is_blocked" :class="fieldLabelClass">
Mark ticket as blocked
</Label>
</div>
<div class="grid gap-2">
<Label for="blocked_comment" :class="fieldLabelClass">
Blocker comment
</Label>
<Textarea
id="blocked_comment"
v-model="form.blocked_comment"
:disabled="!canEditTicket || form.processing"
placeholder="Describe what currently blocks progress on this ticket."
:class="textareaClass"
/>
<InputError :message="form.errors.blocked_comment" />
</div>
</div>
<div class="grid gap-4">
<div class="space-y-1">
<h3 class="text-sm font-semibold">Custom fields</h3>
<p class="text-muted-foreground text-sm">
Project-configured fields can be tracked directly on
the ticket.
</p>
</div>
<div
v-if="ticketFieldDefinitions.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No custom fields are configured for this project yet.
</div>
<div v-else class="grid gap-4 md:grid-cols-2">
<div
v-for="(
ticketFieldDefinition, index
) in ticketFieldDefinitions"
:key="ticketFieldDefinition.id"
class="grid gap-2"
>
<Label
:for="`field-${ticketFieldDefinition.id}`"
:class="fieldLabelClass"
>
{{ ticketFieldDefinition.label }}
</Label>
<select
v-if="
ticketFieldDefinition.field_type === 'user'
"
:id="`field-${ticketFieldDefinition.id}`"
v-model="form.field_values[index].value_user_id"
:disabled="!canEditTicket || form.processing"
:class="nativeSelectClass"
>
<option value="">Unassigned</option>
<option
v-for="user in users"
:key="user.id"
:value="user.id"
>
{{ user.name }}
</option>
</select>
<Input
v-else
:id="`field-${ticketFieldDefinition.id}`"
v-model="form.field_values[index].value_number"
type="number"
step="0.1"
:disabled="!canEditTicket || form.processing"
:class="inputClass"
/>
<InputError
:message="
form.errors[
`field_values.${index}.value_user_id`
] ??
form.errors[
`field_values.${index}.value_number`
]
"
/>
</div>
</div>
</div>
<div v-if="canEditTicket" class="flex items-center gap-3">
<Button type="submit" :disabled="form.processing">
Save changes
</Button>
</div>
</form>
</CardContent>
</Card>
</template>
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { Form, Link } from '@inertiajs/vue3';
import ProjectTicketLinkController from '@/actions/App/Http/Controllers/ProjectTicketLinkController';
import InputError from '@/components/InputError.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { frostedAsideCardClass, nativeSelectClass } from '@/lib/ticket-styles';
import { show as showTicket } from '@/routes/projects/tickets';
import type { TicketLinkModel } from '@/types/models/ticket-link-model';
import type { TicketLinkTypeModel } from '@/types/models/ticket-link-type-model';
import type { TicketReferenceModel } from '@/types/models/ticket-reference-model';
defineProps<{
canEditTicket: boolean;
linkedTickets: TicketLinkModel[];
projectId: number;
ticketId: number;
ticketLinkOptions: TicketReferenceModel[];
ticketLinkTypes: TicketLinkTypeModel[];
}>();
</script>
<template>
<Card :class="frostedAsideCardClass">
<CardHeader>
<CardTitle>Linked tickets</CardTitle>
<CardDescription>
Track cross-ticket relationships with project-defined link
labels.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<Form
v-if="
canEditTicket &&
ticketLinkTypes.length > 0 &&
ticketLinkOptions.length > 0
"
:action="
ProjectTicketLinkController.store.url({
project: projectId,
ticket: ticketId,
})
"
class="border-border grid gap-4 rounded-xl border border-dashed p-4"
method="post"
v-slot="{ errors, processing }"
>
<div class="grid gap-2">
<Label for="ticket_link_type_id">Link type</Label>
<select
id="ticket_link_type_id"
name="ticket_link_type_id"
:class="nativeSelectClass"
>
<option
v-for="ticketLinkType in ticketLinkTypes"
:key="ticketLinkType.id"
:value="ticketLinkType.id"
>
{{ ticketLinkType.label }}
</option>
</select>
<InputError :message="errors.ticket_link_type_id" />
</div>
<div class="grid gap-2">
<Label for="linked_ticket_id">Linked ticket</Label>
<select
id="linked_ticket_id"
name="linked_ticket_id"
:class="nativeSelectClass"
>
<option
v-for="ticketLinkOption in ticketLinkOptions"
:key="ticketLinkOption.id"
:value="ticketLinkOption.id"
>
{{
`${ticketLinkOption.ticket_key} - ${ticketLinkOption.title}`
}}
</option>
</select>
<InputError :message="errors.linked_ticket_id" />
</div>
<Button type="submit" :disabled="processing">Save link</Button>
</Form>
<div
v-if="linkedTickets.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No ticket links are configured for this ticket yet.
</div>
<div v-else class="space-y-3">
<div
v-for="linkedTicket in linkedTickets"
:key="linkedTicket.id"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{ linkedTicket.ticket_link_type.label }}
</Badge>
<Link
:href="
showTicket({
project: projectId,
ticket: linkedTicket.linked_ticket.id,
})
"
class="text-foreground hover:text-primary font-medium transition"
>
{{ linkedTicket.linked_ticket.ticket_key }}
</Link>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{ linkedTicket.linked_ticket.title }}
</p>
<Form
v-if="canEditTicket"
:action="
ProjectTicketLinkController.destroy.url({
project: projectId,
ticket: ticketId,
ticketLink: linkedTicket.id,
})
"
class="mt-3 flex justify-end"
method="delete"
v-slot="{ processing: deleting }"
>
<Button
type="submit"
variant="destructive"
size="sm"
:disabled="deleting"
>
Remove
</Button>
</Form>
</div>
</div>
</CardContent>
</Card>
</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,134 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { frostedAsideCardClass } from '@/lib/ticket-styles';
import { edit as editGitConnections } from '@/routes/git-connections/index';
import type { TicketRepositoryPullRequestModel } from '@/types/models/ticket-repository-pull-request-model';
const props = defineProps<{
repositoryPullRequests: TicketRepositoryPullRequestModel[];
}>();
const visibleRepositoryPullRequests = computed(() =>
props.repositoryPullRequests.filter(
(repositoryPullRequest) =>
repositoryPullRequest.pull_requests.length > 0 ||
repositoryPullRequest.error !== null ||
!repositoryPullRequest.repository_connection.has_personal_access,
),
);
</script>
<template>
<Card :class="frostedAsideCardClass">
<CardHeader>
<CardTitle>Pull requests</CardTitle>
<CardDescription>
Matching pull requests are detected from linked repositories by
checking whether the source branch starts with this ticket key.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="visibleRepositoryPullRequests.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No matching pull requests were found across the linked
repositories you can access.
</div>
<div v-else class="space-y-3">
<div
v-for="repositoryPullRequest in visibleRepositoryPullRequests"
:key="repositoryPullRequest.repository_connection.id"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{{
repositoryPullRequest.repository_connection
.provider_label
}}
</Badge>
<span class="font-medium">
{{
repositoryPullRequest.repository_connection
.repository_full_name
}}
</span>
</div>
<p class="text-muted-foreground mt-2 text-sm">
{{
repositoryPullRequest.repository_connection.base_url
}}
</p>
<div
v-if="
!repositoryPullRequest.repository_connection
.has_personal_access
"
class="mt-3 space-y-3"
>
<p class="text-muted-foreground text-sm">
Connect a matching Git account in your user settings
to inspect pull requests for this repository.
</p>
<Button as-child variant="outline" size="sm">
<Link :href="editGitConnections()">
Open Git connections
</Link>
</Button>
</div>
<div
v-else-if="repositoryPullRequest.error"
class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-100"
>
{{ repositoryPullRequest.error }}
</div>
<div v-else class="mt-3 space-y-3">
<div
v-for="pullRequest in repositoryPullRequest.pull_requests"
:key="pullRequest.html_url"
class="border-border/70 rounded-xl border px-4 py-3"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">
#{{ pullRequest.number }}
</Badge>
<Badge variant="secondary">
{{ pullRequest.status }}
</Badge>
<a
:href="pullRequest.html_url"
target="_blank"
rel="noreferrer"
class="text-foreground hover:text-primary font-medium transition"
>
{{ pullRequest.title }}
</a>
</div>
<p class="text-muted-foreground mt-2 text-sm">
Source branch: {{ pullRequest.source_branch }}
</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { computed } from 'vue';
import FeatureComingSoonPanel from '@/components/FeatureComingSoonPanel.vue';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { frostedAsideCardClass } from '@/lib/ticket-styles';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketRepositoryBranchModel } from '@/types/models/ticket-repository-branch-model';
import type { TicketRepositoryPullRequestModel } from '@/types/models/ticket-repository-pull-request-model';
const props = defineProps<{
repositoryBranches: TicketRepositoryBranchModel[];
repositoryPullRequests: TicketRepositoryPullRequestModel[];
ticket: TicketModel;
}>();
const repositoryBranchCount = computed(() =>
props.repositoryBranches.reduce(
(count, repositoryBranch) => count + repositoryBranch.branches.length,
0,
),
);
const pullRequestCount = computed(() =>
props.repositoryPullRequests.reduce(
(count, repositoryPullRequest) =>
count + repositoryPullRequest.pull_requests.length,
0,
),
);
const formatDateTime = (value: string): string => {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
};
</script>
<template>
<div class="space-y-6">
<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]"
>
{{ ticket.assignee?.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]"
>
{{ ticket.author?.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>
</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>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { frostedAsideCardClass } from '@/lib/ticket-styles';
import type { TicketWorklogEntryModel } from '@/types/models/ticket-worklog-entry-model';
defineProps<{
worklogEntries: TicketWorklogEntryModel[];
}>();
const formatDateTime = (value: string): string => {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
};
</script>
<template>
<Card :class="frostedAsideCardClass">
<CardHeader>
<CardTitle>Worklog</CardTitle>
<CardDescription>
Every tracked ticket change is recorded with actor and
timestamp.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div
v-if="worklogEntries.length === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
>
No worklog entries exist for this ticket yet.
</div>
<div v-else class="flex flex-col gap-4">
<div
v-for="worklogEntry in worklogEntries"
:key="worklogEntry.id"
class="border-border rounded-xl border px-4 py-4"
>
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{{ worklogEntry.entry_type }}
</Badge>
<span class="text-foreground text-sm font-medium">
{{ worklogEntry.actor?.name ?? 'System' }}
</span>
</div>
<p class="text-foreground mt-3 text-sm">
{{ worklogEntry.summary }}
</p>
<p class="text-muted-foreground mt-2 text-xs">
{{ formatDateTime(worklogEntry.created_at) }}
</p>
</div>
</div>
</CardContent>
</Card>
</template>
+34 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { LogOut, Settings } from 'lucide-vue-next';
import { LogOut, Monitor, Moon, Settings, Sun } from 'lucide-vue-next';
import {
DropdownMenuGroup,
DropdownMenuItem,
@@ -8,6 +8,7 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import UserInfo from '@/components/UserInfo.vue';
import { useAppearance } from '@/composables/useAppearance';
import { logout } from '@/routes/index';
import { edit } from '@/routes/profile/index';
import type { User } from '@/types';
@@ -21,6 +22,8 @@ const handleLogout = () => {
};
defineProps<Props>();
const { appearance, updateAppearance } = useAppearance();
</script>
<template>
@@ -39,6 +42,36 @@ defineProps<Props>();
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel class="text-muted-foreground text-xs font-medium">
Appearance
</DropdownMenuLabel>
<DropdownMenuItem
class="cursor-pointer"
:class="{ 'bg-accent': appearance === 'light' }"
@click="updateAppearance('light')"
>
<Sun class="mr-2 h-4 w-4" />
Light
</DropdownMenuItem>
<DropdownMenuItem
class="cursor-pointer"
:class="{ 'bg-accent': appearance === 'dark' }"
@click="updateAppearance('dark')"
>
<Moon class="mr-2 h-4 w-4" />
Dark
</DropdownMenuItem>
<DropdownMenuItem
class="cursor-pointer"
:class="{ 'bg-accent': appearance === 'system' }"
@click="updateAppearance('system')"
>
<Monitor class="mr-2 h-4 w-4" />
System
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem :as-child="true">
<Link
class="block w-full cursor-pointer"
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { PopoverRootEmits, PopoverRootProps } from "reka-ui"
import { PopoverRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<PopoverRootProps>()
const emits = defineEmits<PopoverRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<PopoverRoot
v-slot="slotProps"
data-slot="popover"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</PopoverRoot>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverAnchorProps } from "reka-ui"
import { PopoverAnchor } from "reka-ui"
const props = defineProps<PopoverAnchorProps>()
</script>
<template>
<PopoverAnchor
data-slot="popover-anchor"
v-bind="props"
>
<slot />
</PopoverAnchor>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
PopoverContent,
PopoverPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<PopoverContentProps & { class?: HTMLAttributes["class"] }>(),
{
align: "center",
sideOffset: 4,
},
)
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
data-slot="popover-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
props.class,
)
"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverTriggerProps } from "reka-ui"
import { PopoverTrigger } from "reka-ui"
const props = defineProps<PopoverTriggerProps>()
</script>
<template>
<PopoverTrigger
data-slot="popover-trigger"
v-bind="props"
>
<slot />
</PopoverTrigger>
</template>
@@ -0,0 +1,4 @@
export { default as Popover } from "./Popover.vue"
export { default as PopoverAnchor } from "./PopoverAnchor.vue"
export { default as PopoverContent } from "./PopoverContent.vue"
export { default as PopoverTrigger } from "./PopoverTrigger.vue"
@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { ProgressRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ProgressIndicator,
ProgressRoot,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(
defineProps<ProgressRootProps & { class?: HTMLAttributes["class"] }>(),
{
modelValue: 0,
},
)
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ProgressRoot
data-slot="progress"
v-bind="delegatedProps"
:class="
cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
props.class,
)
"
>
<ProgressIndicator
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
/>
</ProgressRoot>
</template>
@@ -0,0 +1 @@
export { default as Progress } from "./Progress.vue"
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { RadioGroupRootEmits, RadioGroupRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { RadioGroupRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RadioGroupRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<RadioGroupRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<RadioGroupRoot
v-slot="slotProps"
data-slot="radio-group"
:class="cn('grid gap-3', props.class)"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</RadioGroupRoot>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { RadioGroupItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CircleIcon } from "lucide-vue-next"
import {
RadioGroupIndicator,
RadioGroupItem,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<RadioGroupItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<RadioGroupItem
data-slot="radio-group-item"
v-bind="forwardedProps"
:class="
cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<RadioGroupIndicator
data-slot="radio-group-indicator"
class="relative flex items-center justify-center"
>
<slot>
<CircleIcon class="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</slot>
</RadioGroupIndicator>
</RadioGroupItem>
</template>
@@ -0,0 +1,2 @@
export { default as RadioGroup } from "./RadioGroup.vue"
export { default as RadioGroupItem } from "./RadioGroupItem.vue"
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ScrollAreaRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaViewport,
} from "reka-ui"
import { cn } from "@/lib/utils"
import ScrollBar from "./ScrollBar.vue"
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ScrollAreaRoot
data-slot="scroll-area"
v-bind="delegatedProps"
:class="cn('relative', props.class)"
>
<ScrollAreaViewport
data-slot="scroll-area-viewport"
class="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { ScrollAreaScrollbarProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ScrollAreaScrollbar, ScrollAreaThumb } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes["class"] }>(), {
orientation: "vertical",
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
v-bind="delegatedProps"
:class="
cn('flex touch-none p-px transition-colors select-none',
orientation === 'vertical'
&& 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal'
&& 'h-2.5 flex-col border-t border-t-transparent',
props.class)"
>
<ScrollAreaThumb
data-slot="scroll-area-thumb"
class="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaScrollbar>
</template>
@@ -0,0 +1,2 @@
export { default as ScrollArea } from "./ScrollArea.vue"
export { default as ScrollBar } from "./ScrollBar.vue"
@@ -0,0 +1,42 @@
<script lang="ts" setup>
import type { ToasterProps } from "vue-sonner"
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "lucide-vue-next"
import { Toaster as Sonner } from "vue-sonner"
import { cn } from "@/lib/utils"
const props = defineProps<ToasterProps>()
</script>
<template>
<Sonner
:class="cn('toaster group', props.class)"
:style="{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
}"
v-bind="props"
>
<template #success-icon>
<CircleCheckIcon class="size-4" />
</template>
<template #info-icon>
<InfoIcon class="size-4" />
</template>
<template #warning-icon>
<TriangleAlertIcon class="size-4" />
</template>
<template #error-icon>
<OctagonXIcon class="size-4" />
</template>
<template #loading-icon>
<div>
<Loader2Icon class="size-4 animate-spin" />
</div>
</template>
<template #close-icon>
<XIcon class="size-4" />
</template>
</Sonner>
</template>
@@ -0,0 +1 @@
export { default as Toaster } from "./Sonner.vue"
@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { SwitchRootEmits, SwitchRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
SwitchRoot,
SwitchThumb,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<SwitchRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SwitchRoot
v-slot="slotProps"
data-slot="switch"
v-bind="forwarded"
:class="cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)"
>
<SwitchThumb
data-slot="switch-thumb"
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
>
<slot name="thumb" v-bind="slotProps" />
</SwitchThumb>
</SwitchRoot>
</template>
@@ -0,0 +1 @@
export { default as Switch } from "./Switch.vue"
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tbody
data-slot="table-body"
:class="cn('[&_tr:last-child]:border-0', props.class)"
>
<slot />
</tbody>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<caption
data-slot="table-caption"
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
>
<slot />
</caption>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<td
data-slot="table-cell"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
props.class,
)
"
>
<slot />
</td>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { cn } from "@/lib/utils"
import TableCell from "./TableCell.vue"
import TableRow from "./TableRow.vue"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
colspan?: number
}>(), {
colspan: 1,
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<th
data-slot="table-head"
:class="cn('text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
>
<slot />
</th>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<thead
data-slot="table-header"
:class="cn('[&_tr]:border-b', props.class)"
>
<slot />
</thead>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tr
data-slot="table-row"
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
>
<slot />
</tr>
</template>
@@ -0,0 +1,9 @@
export { default as Table } from "./Table.vue"
export { default as TableBody } from "./TableBody.vue"
export { default as TableCaption } from "./TableCaption.vue"
export { default as TableCell } from "./TableCell.vue"
export { default as TableEmpty } from "./TableEmpty.vue"
export { default as TableFooter } from "./TableFooter.vue"
export { default as TableHead } from "./TableHead.vue"
export { default as TableHeader } from "./TableHeader.vue"
export { default as TableRow } from "./TableRow.vue"
+10
View File
@@ -0,0 +1,10 @@
import type { Updater } from "@tanstack/vue-table"
import type { Ref } from "vue"
import { isFunction } from "@tanstack/vue-table"
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
ref.value = isFunction(updaterOrValue)
? updaterOrValue(ref.value)
: updaterOrValue
}
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { TabsRootEmits, TabsRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { TabsRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<TabsRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<TabsRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TabsRoot
v-slot="slotProps"
data-slot="tabs"
v-bind="forwarded"
:class="cn('flex flex-col gap-2', props.class)"
>
<slot v-bind="slotProps" />
</TabsRoot>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { TabsContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { TabsContent } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<TabsContentProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<TabsContent
data-slot="tabs-content"
:class="cn('flex-1 outline-none', props.class)"
v-bind="delegatedProps"
>
<slot />
</TabsContent>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { TabsListProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { TabsList } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<TabsListProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<TabsList
data-slot="tabs-list"
v-bind="delegatedProps"
:class="cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
props.class,
)"
>
<slot />
</TabsList>
</template>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { TabsTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { TabsTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<TabsTrigger
data-slot="tabs-trigger"
:class="cn(
'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</TabsTrigger>
</template>
+4
View File
@@ -0,0 +1,4 @@
export { default as Tabs } from "./Tabs.vue"
export { default as TabsContent } from "./TabsContent.vue"
export { default as TabsList } from "./TabsList.vue"
export { default as TabsTrigger } from "./TabsTrigger.vue"
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
defaultValue?: string | number
modelValue?: string | number
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<textarea
v-model="modelValue"
data-slot="textarea"
:class="cn('border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', props.class)"
/>
</template>
@@ -0,0 +1 @@
export { default as Textarea } from "./Textarea.vue"
@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { VariantProps } from "class-variance-authority"
import type { ToggleGroupRootEmits, ToggleGroupRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { toggleVariants } from '@/components/ui/toggle'
import { reactiveOmit } from "@vueuse/core"
import { ToggleGroupRoot, useForwardPropsEmits } from "reka-ui"
import { provide } from "vue"
import { cn } from "@/lib/utils"
type ToggleGroupVariants = VariantProps<typeof toggleVariants>
const props = withDefaults(defineProps<ToggleGroupRootProps & {
class?: HTMLAttributes["class"]
variant?: ToggleGroupVariants["variant"]
size?: ToggleGroupVariants["size"]
spacing?: number
}>(), {
spacing: 0,
})
const emits = defineEmits<ToggleGroupRootEmits>()
provide("toggleGroup", {
variant: props.variant,
size: props.size,
spacing: props.spacing,
})
const delegatedProps = reactiveOmit(props, "class", "size", "variant")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<ToggleGroupRoot
v-slot="slotProps"
data-slot="toggle-group"
:data-size="size"
:data-variant="variant"
:data-spacing="spacing"
:style="{
'--gap': spacing,
}"
v-bind="forwarded"
:class="cn('group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs', props.class)"
>
<slot v-bind="slotProps" />
</ToggleGroupRoot>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { VariantProps } from "class-variance-authority"
import type { ToggleGroupItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ToggleGroupItem, useForwardProps } from "reka-ui"
import { inject } from "vue"
import { cn } from "@/lib/utils"
import { toggleVariants } from '@/components/ui/toggle'
type ToggleGroupVariants = VariantProps<typeof toggleVariants> & {
spacing?: number
}
const props = defineProps<ToggleGroupItemProps & {
class?: HTMLAttributes["class"]
variant?: ToggleGroupVariants["variant"]
size?: ToggleGroupVariants["size"]
}>()
const context = inject<ToggleGroupVariants>("toggleGroup")
const delegatedProps = reactiveOmit(props, "class", "size", "variant")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ToggleGroupItem
v-slot="slotProps"
data-slot="toggle-group-item"
:data-variant="context?.variant || variant"
:data-size="context?.size || size"
:data-spacing="context?.spacing"
v-bind="forwardedProps"
:class="cn(
toggleVariants({
variant: context?.variant || variant,
size: context?.size || size,
}),
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l',
props.class)"
>
<slot v-bind="slotProps" />
</ToggleGroupItem>
</template>
@@ -0,0 +1,2 @@
export { default as ToggleGroup } from "./ToggleGroup.vue"
export { default as ToggleGroupItem } from "./ToggleGroupItem.vue"
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { ToggleEmits, ToggleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ToggleVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Toggle, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import { toggleVariants } from "."
const props = withDefaults(defineProps<ToggleProps & {
class?: HTMLAttributes["class"]
variant?: ToggleVariants["variant"]
size?: ToggleVariants["size"]
}>(), {
variant: "default",
size: "default",
disabled: false,
})
const emits = defineEmits<ToggleEmits>()
const delegatedProps = reactiveOmit(props, "class", "size", "variant")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<Toggle
v-slot="slotProps"
data-slot="toggle"
v-bind="forwarded"
:class="cn(toggleVariants({ variant, size }), props.class)"
>
<slot v-bind="slotProps" />
</Toggle>
</template>
@@ -0,0 +1,28 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Toggle } from "./Toggle.vue"
export const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ToggleVariants = VariantProps<typeof toggleVariants>
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
import AppLayout from '@/layouts/app/AppHeaderLayout.vue';
import type { BreadcrumbItem } from '@/types';
type Props = {
+26
View File
@@ -0,0 +1,26 @@
export 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';
export 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';
export 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';
export 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';
export 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';
export 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';
export 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';
export 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';
export const fieldLabelClass =
'text-[0.72rem] font-semibold uppercase tracking-[0.18em] text-[#5a6062]';
+4 -1
View File
@@ -15,7 +15,10 @@ import AppLayout from '@/layouts/AppLayout.vue';
import { index as boardIndex } from '@/routes/projects/board';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index, show } from '@/routes/projects/index';
import { create as createTicket, show as showTicket } from '@/routes/projects/tickets';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
+4 -1
View File
@@ -16,7 +16,10 @@ import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as projectIndex, show } from '@/routes/projects/index';
import { board as boardSettings } from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board';
import { create as createTicket, show as showTicket } from '@/routes/projects/tickets';
import {
create as createTicket,
show as showTicket,
} from '@/routes/projects/tickets';
import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types';
import type { TicketModel } from '@/types/models/ticket-model';
import type { TicketStatusModel } from '@/types/models/ticket-status-model';
File diff suppressed because it is too large Load Diff
+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>
@@ -294,9 +294,9 @@ const submitUpdate = (): void => {
</Link>
</Button>
<p class="text-muted-foreground text-sm">
Without a matching personal connection, {{ appName }} only
shows the repository name and links you to your user
settings.
Without a matching personal connection,
{{ appName }} only shows the repository name and links
you to your user settings.
</p>
</CardContent>
</Card>
@@ -621,8 +621,8 @@ const submitUpdate = (): void => {
>
This project link is visible, but you need a
matching Git connection in your user
settings before {{ appName }} can open or query
this repository for you.
settings before {{ appName }} can open or
query this repository for you.
</p>
<div class="mt-4 flex justify-end">
@@ -657,9 +657,9 @@ const submitUpdate = (): void => {
<CardHeader class="border-b">
<CardTitle>Detected personal sources</CardTitle>
<CardDescription>
{{ appName }} loads repositories from your personal
Git connections and offers them for project
linking.
{{ appName }} loads repositories from your
personal Git connections and offers them for
project linking.
</CardDescription>
</CardHeader>
@@ -46,8 +46,9 @@ const breadcrumbItems: BreadcrumbItem[] = [
/>
<p class="text-muted-foreground text-sm">
Each user needs their own application key. Without a
matching personal connection, {{ appName }} can only show
repository names and will link you back to this page.
matching personal connection, {{ appName }} can only
show repository names and will link you back to this
page.
</p>
</div>
+3 -3
View File
@@ -22,11 +22,11 @@
{{-- Inline style to set the HTML background color based on our theme in app.css --}}
<style>
html {
background-color: oklch(1 0 0);
background-color: hsl(120 10% 98%);
}
html.dark {
background-color: oklch(0.145 0 0);
background-color: hsl(120 5% 6%);
}
</style>
@@ -37,7 +37,7 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700|manrope:500,600,700,800" rel="stylesheet" />
@vite(['resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"])
@inertiaHead