Merge branch 'codex/stitch-ticket-pages' into staging
This commit is contained in:
+58
-55
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,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 = {
|
||||
|
||||
@@ -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]';
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
+164
-1337
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user