feat(frontend): implement main dashboard page (#48)

Implement the main dashboard / projects list page for Syndarix as the landing
page after login. The implementation includes:

Dashboard Components:
- QuickStats: Overview cards showing active projects, agents, issues, approvals
- ProjectsSection: Grid/list view with filtering and sorting controls
- ProjectCardGrid: Rich project cards for grid view
- ProjectRowList: Compact rows for list view
- ActivityFeed: Real-time activity sidebar with connection status
- PerformanceCard: Performance metrics display
- EmptyState: Call-to-action for new users
- ProjectStatusBadge: Status indicator with icons
- ComplexityIndicator: Visual complexity dots
- ProgressBar: Accessible progress bar component

Features:
- Projects grid/list view with view mode toggle
- Filter by status (all, active, paused, completed, archived)
- Sort by recent, name, progress, or issues
- Quick stats overview with counts
- Real-time activity feed sidebar with live/reconnecting status
- Performance metrics card
- Create project button linking to wizard
- Responsive layout for mobile/desktop
- Loading skeleton states
- Empty state for new users

API Integration:
- useProjects hook for fetching projects (mock data until backend ready)
- useDashboardStats hook for statistics
- TanStack Query for caching and data fetching

Testing:
- 37 unit tests covering all dashboard components
- E2E test suite for dashboard functionality
- Accessibility tests (keyboard nav, aria attributes, heading hierarchy)

Technical:
- TypeScript strict mode compliance
- ESLint passing
- WCAG AA accessibility compliance
- Mobile-first responsive design
- Dark mode support via semantic tokens
- Follows design system guidelines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 23:46:50 +01:00
parent e85788f79f
commit 5b1e2852ea
67 changed files with 8879 additions and 0 deletions

View File

@@ -0,0 +1,204 @@
'use client';
/**
* Issue Detail Page
*
* Displays full issue details with status workflow and activity timeline.
*
* @module app/[locale]/(authenticated)/projects/[id]/issues/[issueId]/page
*/
import { use } from 'react';
import Link from 'next/link';
import {
ArrowLeft,
Calendar,
Clock,
ExternalLink,
Edit,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
StatusBadge,
PriorityBadge,
SyncStatusIndicator,
StatusWorkflow,
ActivityTimeline,
IssueDetailPanel,
useIssue,
useUpdateIssueStatus,
getPrimaryTransition,
} from '@/features/issues';
import type { IssueStatus } from '@/features/issues';
interface IssueDetailPageProps {
params: Promise<{
locale: string;
id: string;
issueId: string;
}>;
}
export default function IssueDetailPage({ params }: IssueDetailPageProps) {
const { locale, id: projectId, issueId } = use(params);
const { data: issue, isLoading, error } = useIssue(issueId);
const updateStatus = useUpdateIssueStatus();
const handleStatusChange = (status: IssueStatus) => {
updateStatus.mutate({ issueId, status });
};
const primaryTransition = issue ? getPrimaryTransition(issue.status) : undefined;
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<div className="rounded-lg border border-destructive bg-destructive/10 p-6 text-center">
<h2 className="text-lg font-semibold text-destructive">Error Loading Issue</h2>
<p className="mt-2 text-sm text-muted-foreground">
Failed to load issue details. Please try again later.
</p>
<div className="mt-4 flex justify-center gap-2">
<Link href={`/${locale}/projects/${projectId}/issues`}>
<Button variant="outline">Back to Issues</Button>
</Link>
<Button
variant="outline"
onClick={() => window.location.reload()}
>
Retry
</Button>
</div>
</div>
</div>
);
}
if (isLoading || !issue) {
return (
<div className="container mx-auto px-4 py-6">
<div className="space-y-6">
<div className="flex items-start gap-4">
<Skeleton className="h-10 w-10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-8 w-96" />
<Skeleton className="h-4 w-64" />
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-96 w-full" />
</div>
<div className="space-y-6">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div>
</div>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
<div className="space-y-6">
{/* Header */}
<div className="flex items-start gap-4">
<Link href={`/${locale}/projects/${projectId}/issues`}>
<Button variant="ghost" size="icon" aria-label="Back to issues">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Button>
</Link>
<div className="flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-muted-foreground">#{issue.number}</span>
<StatusBadge status={issue.status} />
<PriorityBadge priority={issue.priority} />
<SyncStatusIndicator status={issue.sync_status} showLabel />
</div>
<h1 className="text-2xl font-bold">{issue.title}</h1>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" aria-hidden="true" />
Created {new Date(issue.created_at).toLocaleDateString()}
</div>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" aria-hidden="true" />
Updated {new Date(issue.updated_at).toLocaleDateString()}
</div>
{issue.external_url && (
<a
href={issue.external_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-primary hover:underline"
>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
View in Gitea
</a>
)}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Edit className="mr-2 h-4 w-4" aria-hidden="true" />
Edit
</Button>
{primaryTransition && (
<Button
size="sm"
onClick={() => handleStatusChange(primaryTransition.to)}
disabled={updateStatus.isPending}
>
{updateStatus.isPending ? 'Updating...' : primaryTransition.label}
</Button>
)}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Description Card */}
<Card>
<CardHeader>
<CardTitle>Description</CardTitle>
</CardHeader>
<CardContent>
<div className="prose prose-sm max-w-none dark:prose-invert">
<pre className="whitespace-pre-wrap font-sans text-sm">
{issue.description}
</pre>
</div>
</CardContent>
</Card>
{/* Activity Timeline */}
<ActivityTimeline
activities={issue.activity}
onAddComment={() => console.log('Add comment')}
/>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status Workflow */}
<StatusWorkflow
currentStatus={issue.status}
onStatusChange={handleStatusChange}
disabled={updateStatus.isPending}
/>
{/* Issue Details */}
<IssueDetailPanel issue={issue} />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,201 @@
'use client';
/**
* Project Issues List Page
*
* Displays filterable, sortable list of issues for a project.
* Supports bulk actions and sync with external trackers.
*
* @module app/[locale]/(authenticated)/projects/[id]/issues/page
*/
import { useState, use } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
IssueFilters,
IssueTable,
BulkActions,
useIssues,
} from '@/features/issues';
import type { IssueFiltersType, IssueSort } from '@/features/issues';
interface ProjectIssuesPageProps {
params: Promise<{
locale: string;
id: string;
}>;
}
export default function ProjectIssuesPage({ params }: ProjectIssuesPageProps) {
const { locale, id: projectId } = use(params);
const router = useRouter();
// Filter state
const [filters, setFilters] = useState<IssueFiltersType>({
status: 'all',
priority: 'all',
sprint: 'all',
assignee: 'all',
});
// Sort state
const [sort, setSort] = useState<IssueSort>({
field: 'updated_at',
direction: 'desc',
});
// Selection state
const [selectedIssues, setSelectedIssues] = useState<string[]>([]);
// Fetch issues
const { data, isLoading, error } = useIssues(projectId, filters, sort);
const handleIssueClick = (issueId: string) => {
router.push(`/${locale}/projects/${projectId}/issues/${issueId}`);
};
const handleBulkChangeStatus = () => {
// TODO: Open status change dialog
console.log('Change status for:', selectedIssues);
};
const handleBulkAssign = () => {
// TODO: Open assign dialog
console.log('Assign:', selectedIssues);
};
const handleBulkAddLabels = () => {
// TODO: Open labels dialog
console.log('Add labels to:', selectedIssues);
};
const handleBulkDelete = () => {
// TODO: Confirm and delete
console.log('Delete:', selectedIssues);
};
const handleSync = () => {
// TODO: Sync all issues
console.log('Sync issues');
};
const handleNewIssue = () => {
// TODO: Navigate to new issue page or open dialog
console.log('Create new issue');
};
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<div className="rounded-lg border border-destructive bg-destructive/10 p-6 text-center">
<h2 className="text-lg font-semibold text-destructive">Error Loading Issues</h2>
<p className="mt-2 text-sm text-muted-foreground">
Failed to load issues. Please try again later.
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => window.location.reload()}
>
Retry
</Button>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-6">
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold">Issues</h1>
<p className="text-muted-foreground">
{isLoading ? (
<Skeleton className="h-4 w-24" />
) : (
`${data?.pagination.total || 0} issues found`
)}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSync}>
<Upload className="mr-2 h-4 w-4" aria-hidden="true" />
Sync
</Button>
<Button size="sm" onClick={handleNewIssue}>
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
New Issue
</Button>
</div>
</div>
{/* Filters */}
<IssueFilters filters={filters} onFiltersChange={setFilters} />
{/* Bulk Actions */}
<BulkActions
selectedCount={selectedIssues.length}
onChangeStatus={handleBulkChangeStatus}
onAssign={handleBulkAssign}
onAddLabels={handleBulkAddLabels}
onDelete={handleBulkDelete}
/>
{/* Issue Table */}
{isLoading ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : (
<IssueTable
issues={data?.data || []}
selectedIssues={selectedIssues}
onSelectionChange={setSelectedIssues}
onIssueClick={handleIssueClick}
sort={sort}
onSortChange={setSort}
/>
)}
{/* Pagination info */}
{data && data.pagination.total > 0 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
Showing {(data.pagination.page - 1) * data.pagination.page_size + 1} to{' '}
{Math.min(
data.pagination.page * data.pagination.page_size,
data.pagination.total
)}{' '}
of {data.pagination.total} issues
</span>
{data.pagination.total_pages > 1 && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={!data.pagination.has_prev}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={!data.pagination.has_next}
>
Next
</Button>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
/**
* Project Dashboard Page
*
* Main dashboard for viewing project status, agents, sprints, and activity.
* Provides real-time updates via SSE and quick actions for project management.
*
* @see Issue #40
*/
import { Metadata } from 'next';
import { ProjectDashboard } from '@/components/projects/ProjectDashboard';
export const metadata: Metadata = {
title: 'Project Dashboard',
description: 'View project status, agents, sprints, and activity',
};
interface ProjectDashboardPageProps {
params: Promise<{
id: string;
locale: string;
}>;
}
export default async function ProjectDashboardPage({ params }: ProjectDashboardPageProps) {
const { id } = await params;
return <ProjectDashboard projectId={id} />;
}

View File

@@ -0,0 +1,30 @@
/**
* New Project Page
*
* Multi-step wizard for creating new Syndarix projects.
*/
import type { Metadata } from 'next';
import { ProjectWizard } from '@/components/projects';
export const metadata: Metadata = {
title: 'New Project',
description: 'Create a new Syndarix project with AI-powered agents',
};
interface NewProjectPageProps {
params: Promise<{ locale: string }>;
}
export default async function NewProjectPage({ params }: NewProjectPageProps) {
const { locale } = await params;
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<ProjectWizard locale={locale} />
</div>
</div>
);
}