feat(KAN-25): build personal dashboard
This commit is contained in:
@@ -118,6 +118,24 @@ export const en = {
|
||||
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
|
||||
'projects.inheritedArea': '{name} · inherited',
|
||||
'projects.overriddenArea': '{name} · overridden',
|
||||
'dashboard.eyebrow': 'Personal workspace',
|
||||
'dashboard.greeting': 'Welcome back, {name}',
|
||||
'dashboard.description':
|
||||
'A current view of tickets assigned to you in projects you can access.',
|
||||
'dashboard.summary': 'Work summary',
|
||||
'dashboard.summary.assignedTickets': 'Assigned tickets',
|
||||
'dashboard.summary.projects': 'Projects with assigned work',
|
||||
'dashboard.summary.blockedTickets': 'Blocked tickets',
|
||||
'dashboard.assignedWork': 'My assigned tickets',
|
||||
'dashboard.assignedWorkDescription':
|
||||
'Your work is grouped by project and ordered with the newest tickets first.',
|
||||
'dashboard.projectTickets': 'Tickets assigned to you in {name}',
|
||||
'dashboard.openProject': 'Open {name}',
|
||||
'dashboard.openProjectAction': 'Open project',
|
||||
'dashboard.empty.title': 'No tickets assigned to you',
|
||||
'dashboard.empty.description':
|
||||
'Tickets assigned to you in projects you can access will appear here.',
|
||||
'dashboard.empty.action': 'Browse projects',
|
||||
'auth.home': 'KanbanGit home',
|
||||
'auth.accountAccess': 'Account access',
|
||||
'auth.backToLogin': 'Return to sign in',
|
||||
|
||||
@@ -1,47 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
FolderGit2,
|
||||
ListTodo,
|
||||
OctagonAlert,
|
||||
} from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
import FeedbackState from '@/components/FeedbackState.vue';
|
||||
import { TicketCard } from '@/components/tickets';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useLocalization } from '@/composables/useLocalization';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { dashboard } from '@/routes/index';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
import { index as projectsIndex, show as showProject } from '@/routes/projects';
|
||||
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
const props = defineProps<DashboardPageProps>();
|
||||
const { t } = useLocalization();
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
|
||||
{ title: t('navigation.dashboard'), href: dashboard() },
|
||||
]);
|
||||
const assignedTickets = computed(() =>
|
||||
props.assignedTicketsByProject.flatMap((group) => group.tickets),
|
||||
);
|
||||
const summaryItems = computed(() => [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
label: t('dashboard.summary.assignedTickets'),
|
||||
value: assignedTickets.value.length,
|
||||
icon: ListTodo,
|
||||
},
|
||||
];
|
||||
{
|
||||
label: t('dashboard.summary.projects'),
|
||||
value: props.assignedTicketsByProject.length,
|
||||
icon: FolderGit2,
|
||||
},
|
||||
{
|
||||
label: t('dashboard.summary.blockedTickets'),
|
||||
value: assignedTickets.value.filter((ticket) => ticket.is_blocked)
|
||||
.length,
|
||||
icon: OctagonAlert,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Dashboard" />
|
||||
|
||||
<AppLayout :breadcrumbs="breadcrumbs">
|
||||
<div
|
||||
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
||||
>
|
||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
|
||||
<Head :title="t('navigation.dashboard')" />
|
||||
|
||||
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||
<header class="gap-stack grid min-w-0">
|
||||
<p class="text-label text-muted-foreground uppercase">
|
||||
{{ t('dashboard.eyebrow') }}
|
||||
</p>
|
||||
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||
{{
|
||||
t('dashboard.greeting', {
|
||||
name: $page.props.auth.user.name,
|
||||
})
|
||||
}}
|
||||
</h1>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('dashboard.description') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section :aria-label="t('dashboard.summary')">
|
||||
<ul class="grid gap-3 sm:grid-cols-3">
|
||||
<li
|
||||
v-for="item in summaryItems"
|
||||
:key="item.label"
|
||||
class="min-w-0"
|
||||
>
|
||||
<Card class="h-full gap-0 rounded-md py-0">
|
||||
<CardContent
|
||||
class="flex min-w-0 items-center justify-between gap-4 p-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p
|
||||
class="text-caption text-muted-foreground break-words"
|
||||
>
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="text-title mt-1 tabular-nums">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-labelledby="assigned-work-title"
|
||||
class="gap-column grid min-w-0"
|
||||
>
|
||||
<PlaceholderPattern />
|
||||
</div>
|
||||
<header class="gap-stack grid min-w-0">
|
||||
<h2 id="assigned-work-title" class="text-title">
|
||||
{{ t('dashboard.assignedWork') }}
|
||||
</h2>
|
||||
<p class="text-muted-foreground max-w-2xl">
|
||||
{{ t('dashboard.assignedWorkDescription') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FeedbackState
|
||||
v-if="assignedTicketsByProject.length === 0"
|
||||
state="empty"
|
||||
:title="t('dashboard.empty.title')"
|
||||
:description="t('dashboard.empty.description')"
|
||||
>
|
||||
<Button as-child variant="outline">
|
||||
<Link :href="projectsIndex()">
|
||||
<FolderGit2 aria-hidden="true" />
|
||||
{{ t('dashboard.empty.action') }}
|
||||
</Link>
|
||||
</Button>
|
||||
</FeedbackState>
|
||||
|
||||
<div v-else class="gap-page grid min-w-0">
|
||||
<section
|
||||
v-for="group in assignedTicketsByProject"
|
||||
:key="group.project.id"
|
||||
:aria-labelledby="`dashboard-project-${group.project.id}`"
|
||||
class="gap-column grid min-w-0"
|
||||
>
|
||||
<header
|
||||
class="flex min-w-0 flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="max-w-full shrink-0 whitespace-normal break-all"
|
||||
>
|
||||
{{ group.project.key }}
|
||||
</Badge>
|
||||
<div class="min-w-0">
|
||||
<h3
|
||||
:id="`dashboard-project-${group.project.id}`"
|
||||
class="text-body break-words font-semibold"
|
||||
>
|
||||
{{ group.project.name }}
|
||||
</h3>
|
||||
<p
|
||||
class="text-caption text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
t('tickets.count', {
|
||||
count: group.tickets.length,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
as-child
|
||||
variant="link"
|
||||
class="self-start sm:self-auto"
|
||||
>
|
||||
<Link
|
||||
:href="showProject(group.project.id)"
|
||||
:aria-label="
|
||||
t('dashboard.openProject', {
|
||||
name: group.project.name,
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ t('dashboard.openProjectAction') }}
|
||||
<ArrowUpRight aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<ul
|
||||
:aria-label="
|
||||
t('dashboard.projectTickets', {
|
||||
name: group.project.name,
|
||||
})
|
||||
"
|
||||
class="gap-column grid min-w-0 md:grid-cols-2 2xl:grid-cols-3"
|
||||
>
|
||||
<li
|
||||
v-for="ticket in group.tickets"
|
||||
:key="ticket.id"
|
||||
class="min-w-0"
|
||||
>
|
||||
<TicketCard
|
||||
:project-id="group.project.id"
|
||||
:ticket="ticket"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -39,6 +39,15 @@ export type ProjectDuplicateCopyAreaOption = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type AssignedTicketsProjectGroup = {
|
||||
project: ProjectModel;
|
||||
tickets: TicketModel[];
|
||||
};
|
||||
|
||||
export type DashboardPageProps = {
|
||||
assignedTicketsByProject: AssignedTicketsProjectGroup[];
|
||||
};
|
||||
|
||||
export type ProjectsIndexPageProps = {
|
||||
projects: ProjectModel[];
|
||||
status?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user