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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user