feat(KAN-23): rebuild project overview and entry pages
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s

This commit is contained in:
2026-09-11 14:19:07 +02:00
parent 17caabbb3e
commit 0e78be0eae
7 changed files with 915 additions and 242 deletions
+158 -77
View File
@@ -1,103 +1,184 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Folder, Plus } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import {
ArrowUpRight,
Columns3,
FolderGit2,
ListTodo,
Plus,
} from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { create, index, show } from '@/routes/projects/index';
import { create, index, show } from '@/routes/projects';
import { index as backlogIndex } from '@/routes/projects/backlog';
import { index as boardIndex } from '@/routes/projects/board';
import type { BreadcrumbItem, ProjectsIndexPageProps } from '@/types';
defineProps<ProjectsIndexPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: index(),
},
];
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
]);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head title="Projects" />
<Head :title="t('navigation.projects')" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<header
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
>
<Heading
title="Projects"
description="Create and browse project spaces that will later host boards, backlog, and settings."
/>
<div class="gap-stack grid min-w-0">
<p class="text-label text-muted-foreground uppercase">
{{ t('projects.workspace') }}
</p>
<h1 class="text-headline-mobile md:text-headline">
{{ t('navigation.projects') }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('projects.description') }}
</p>
</div>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
class="self-start"
>
<Link :href="create()">
<Plus class="size-4" />
New project
</Link>
</Button>
</div>
<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"
>
{{ status }}
</div>
<div
v-if="projects.length === 0"
class="text-muted-foreground rounded-xl border border-dashed px-6 py-10 text-center text-sm"
>
No projects exist yet. Create the first one to start building
out board, backlog, and settings areas.
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Link
v-for="project in projects"
:key="project.id"
:href="show(project.id)"
class="group block"
>
<Card
class="border-border/80 group-hover:border-primary/40 h-full transition-colors"
<Link :href="create()"
><Plus aria-hidden="true" />{{
t('projects.create')
}}</Link
>
<CardHeader class="gap-3">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<CardTitle>{{ project.name }}</CardTitle>
<CardDescription>
Project workspace entry point
</CardDescription>
</div>
</Button>
</header>
<Badge variant="outline" class="px-2.5 py-1">
{{ project.key }}
</Badge>
</div>
</CardHeader>
<FeedbackState
v-if="status"
state="success"
:title="t('projects.updated')"
:description="status"
/>
<CardContent
class="text-muted-foreground flex items-center gap-2 text-sm"
<FeedbackState
v-if="projects.length === 0"
state="empty"
:title="t('projects.empty.title')"
:description="
$page.props.auth.permissions.projectCreate
? t('projects.empty.createDescription')
: t('projects.empty.accessDescription')
"
>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
>
<Link :href="create()"
><Plus aria-hidden="true" />{{
t('projects.empty.create')
}}</Link
>
</Button>
</FeedbackState>
<section
v-else
aria-labelledby="project-list-title"
class="gap-column grid"
>
<h2
id="project-list-title"
class="text-caption text-muted-foreground"
>
{{ t('projects.available', { count: projects.length }) }}
</h2>
<ul class="gap-column grid md:grid-cols-2 xl:grid-cols-3">
<li
v-for="project in projects"
:key="project.id"
class="min-w-0"
>
<article
class="bg-card text-card-foreground flex h-full min-w-0 flex-col rounded-md border"
>
<Folder class="size-4" />
Open board, backlog, and settings hub
</CardContent>
</Card>
</Link>
</div>
<Link
:href="show(project.id)"
:aria-label="
t('projects.open', { name: project.name })
"
class="gap-column hover:bg-accent/50 group flex min-w-0 flex-1 items-start rounded-t-md p-5 transition-colors"
>
<span
class="bg-accent text-primary flex size-11 shrink-0 items-center justify-center rounded-md"
>
<FolderGit2
class="size-5"
aria-hidden="true"
/>
</span>
<div class="gap-stack grid min-w-0 flex-1">
<Badge
variant="outline"
class="max-w-full justify-self-start whitespace-normal break-all"
>{{ project.key }}</Badge
>
<h3
class="text-title group-hover:text-primary break-words"
>
{{ project.name }}
</h3>
<span
class="text-caption text-muted-foreground"
>{{ t('projects.overview') }}</span
>
</div>
<ArrowUpRight
class="text-muted-foreground mt-1 size-4 shrink-0"
aria-hidden="true"
/>
</Link>
<div
class="gap-stack p-card grid grid-cols-2 border-t"
>
<Button as-child variant="secondary">
<Link
:href="boardIndex(project.id)"
:aria-label="
t('projects.openBoard', {
name: project.name,
})
"
>
<Columns3 aria-hidden="true" />{{
t('navigation.board')
}}
</Link>
</Button>
<Button as-child variant="ghost">
<Link
:href="backlogIndex(project.id)"
:aria-label="
t('projects.openBacklog', {
name: project.name,
})
"
>
<ListTodo aria-hidden="true" />{{
t('navigation.backlog')
}}
</Link>
</Button>
</div>
</article>
</li>
</ul>
</section>
</div>
</AppLayout>
</template>
+186 -165
View File
@@ -1,206 +1,227 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { FolderGit2, LayoutGrid, ListTodo, Settings2 } from 'lucide-vue-next';
import Heading from '@/components/Heading.vue';
import {
ArrowLeft,
ArrowRight,
Columns3,
Layers3,
ListTodo,
} from 'lucide-vue-next';
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 { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { access, index, show } from '@/routes/projects/index';
import { index as boardIndex } from '@/routes/projects/board';
import { projectSettingsNavigation } from '@/lib/navigation';
import { index, show } from '@/routes/projects';
import { index as backlogIndex } from '@/routes/projects/backlog';
import {
board as boardSettings,
general as generalSettings,
metadata as metadataSettings,
notifications as notificationSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import { index as boardIndex } from '@/routes/projects/board';
import { create as projectTemplateCreate } from '@/routes/projects/templates';
import type { BreadcrumbItem, ProjectShowPageProps } from '@/types';
const props = defineProps<ProjectShowPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: index() },
{ title: props.project.name, href: show(props.project.id) },
]);
const projectAreas = computed(() => [
{
title: 'Projects',
href: index(),
title: t('navigation.board'),
description: t('projects.boardDescription'),
action: t('projects.boardAction'),
href: boardIndex(props.project.id),
icon: Columns3,
},
{
title: props.project.name,
href: show(props.project.id),
},
];
const projectAreas = [
{
title: 'Backlog',
description:
'Create tickets, review incoming work, and move ready items into the board workflow.',
title: t('navigation.backlog'),
description: t('projects.backlogDescription'),
action: t('projects.backlogAction'),
href: backlogIndex(props.project.id),
icon: ListTodo,
},
{
title: 'Board',
description:
'Track active work across the project-defined Kanban columns and update ticket status.',
icon: LayoutGrid,
},
{
title: 'Project settings',
description:
'Manage board columns, ticket metadata, and the project-specific workflow configuration.',
icon: Settings2,
},
{
title: 'Repositories',
description:
'Link project repositories so tickets can later discover related branches and pull requests.',
icon: FolderGit2,
},
];
]);
const settingsItems = computed(() =>
projectSettingsNavigation(
{
id: props.project.id,
name: props.project.name,
key: props.project.key,
canManage: props.canManageAccess,
canCreateTickets: false,
},
t,
),
);
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="project.name" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<Link
:href="index()"
class="text-caption text-muted-foreground hover:text-primary min-h-control inline-flex items-center gap-2 self-start rounded-md"
>
<ArrowLeft class="size-4" aria-hidden="true" />{{
t('projects.back')
}}
</Link>
<header
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="project.name"
description="Project hub for the upcoming backlog, board, and settings work."
/>
</div>
<div class="flex flex-wrap gap-3">
<Button as-child variant="outline">
<Link :href="boardIndex(project.id)">Open board</Link>
</Button>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)">
Open backlog
</Link>
</Button>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
<div class="gap-stack grid min-w-0">
<Badge
variant="outline"
class="max-w-full justify-self-start whitespace-normal break-all"
>{{ project.key }}</Badge
>
<Link :href="projectTemplateCreate(project.id)">
Save as new template
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="index()">Back to projects</Link>
</Button>
<h1
class="text-headline-mobile md:text-headline break-words"
>
{{ project.name }}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('projects.hubDescription') }}
</p>
</div>
</div>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
variant="outline"
class="self-start"
>
<Link :href="projectTemplateCreate(project.id)"
><Layers3 aria-hidden="true" />{{
t('projects.saveTemplate')
}}</Link
>
</Button>
</header>
<Card v-if="templateState?.inheritsFromTemplate">
<CardHeader>
<CardTitle>
Built from template {{ templateState.templateName }}
</CardTitle>
<CardDescription>
This project keeps its template link. Inherited areas
follow the template baseline; overridden areas were
customized locally.
</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-2">
<section
:aria-label="t('projects.workAreas')"
class="gap-column grid md:grid-cols-2"
>
<Link
v-for="area in projectAreas"
:key="area.href.url"
:href="area.href"
:aria-label="area.action"
class="gap-column bg-card hover:border-primary/50 hover:shadow-card-hover group flex min-w-0 flex-col rounded-md border p-5 transition-[border-color,box-shadow]"
>
<span
class="bg-accent text-primary flex size-11 items-center justify-center rounded-md"
>
<component
:is="area.icon"
class="size-5"
aria-hidden="true"
/>
</span>
<div class="gap-stack grid">
<h2 class="text-title">{{ area.title }}</h2>
<p class="text-muted-foreground">
{{ area.description }}
</p>
</div>
<span
class="text-primary mt-auto flex items-center gap-2 pt-2 font-semibold"
>
{{ area.action
}}<ArrowRight class="size-4" aria-hidden="true" />
</span>
</Link>
</section>
<section
v-if="settingsItems.length"
aria-labelledby="project-settings-title"
class="gap-column grid"
>
<div class="gap-stack grid">
<h2 id="project-settings-title" class="text-title">
{{ t('navigation.projectSettings') }}
</h2>
<p class="text-muted-foreground">
{{ t('projects.settingsDescription') }}
</p>
</div>
<ul class="gap-stack grid sm:grid-cols-2 xl:grid-cols-3">
<li
v-for="item in settingsItems"
:key="item.title"
class="min-w-0"
>
<Link
:href="item.href"
class="gap-card bg-card hover:bg-accent hover:text-accent-foreground min-h-control flex h-full items-center rounded-md border p-4 transition-colors"
>
<component
:is="item.icon"
class="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<span
class="min-w-0 flex-1 break-words font-medium"
>{{ item.title }}</span
>
<ArrowRight
class="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
</Link>
</li>
</ul>
</section>
<section
v-if="templateState?.inheritsFromTemplate"
aria-labelledby="project-template-title"
class="gap-column bg-card flex min-w-0 flex-col rounded-md border p-5"
>
<div class="gap-card flex items-start">
<Layers3
class="text-muted-foreground mt-0.5 size-5 shrink-0"
aria-hidden="true"
/>
<div class="gap-stack grid min-w-0">
<h2
id="project-template-title"
class="text-title break-words"
>
{{
t('projects.templateTitle', {
name: templateState.templateName,
})
}}
</h2>
<p class="text-muted-foreground">
{{ t('projects.templateDescription') }}
</p>
</div>
</div>
<div class="gap-stack flex flex-wrap">
<Badge
v-for="area in templateState.inheritedAreas"
:key="`inherited-${area.value}`"
variant="outline"
class="max-w-full whitespace-normal break-words"
>
{{ area.label }} · inherited
{{ t('projects.inheritedArea', { name: area.label }) }}
</Badge>
<Badge
v-for="area in templateState.overriddenAreas"
:key="`overridden-${area.value}`"
variant="secondary"
class="max-w-full whitespace-normal break-words"
>
{{ area.label }} · overridden
{{ t('projects.overriddenArea', { name: area.label }) }}
</Badge>
</CardContent>
</Card>
<div v-if="canManageAccess" class="flex flex-wrap gap-3">
<Button as-child>
<Link :href="boardSettings(project.id)">
Board settings
</Link>
</Button>
<Button
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
<Link :href="generalSettings(project.id)">
General settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="metadataSettings(project.id)">
Ticket settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="repositorySettings(project.id)">
Repository settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="notificationSettings(project.id)">
Notification settings
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="access(project.id)">Manage access</Link>
</Button>
</div>
<div class="grid gap-4 md:grid-cols-3">
<Card
v-for="area in projectAreas"
:key="area.title"
class="h-full"
>
<CardHeader>
<div
class="bg-muted text-foreground mb-3 flex size-10 items-center justify-center rounded-full"
>
<component :is="area.icon" class="size-5" />
</div>
<CardTitle>{{ area.title }}</CardTitle>
<CardDescription>{{
area.description
}}</CardDescription>
</CardHeader>
<CardContent class="text-muted-foreground text-sm">
Use the project shortcuts above to open this area.
</CardContent>
</Card>
</div>
</div>
</section>
</div>
</AppLayout>
</template>