Implement the projects CRUD page with: - ProjectCard: Card component with status badge, progress, metrics, actions - ProjectFilters: Search, status filter, complexity, sort controls - ProjectsGrid: Grid/list view toggle with loading and empty states - useProjects hook: Mock data with filtering, sorting, pagination Features include: - Debounced search (300ms) - Quick filters (status) and extended filters (complexity, sort) - Grid and list view toggle - Click navigation to project detail 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
200 lines
6.5 KiB
TypeScript
200 lines
6.5 KiB
TypeScript
/**
|
|
* ProjectCard Component
|
|
*
|
|
* Displays a project card for the projects grid view.
|
|
* Shows project status, progress, metrics, and quick info.
|
|
*
|
|
* @see Issue #54
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { Bot, CircleDot, Clock, MoreVertical, Archive, Play, Pause } from 'lucide-react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { cn } from '@/lib/utils';
|
|
import { ProjectStatusBadge } from './StatusBadge';
|
|
import { ProgressBar } from './ProgressBar';
|
|
import type { ProjectListItem } from '@/lib/api/hooks/useProjects';
|
|
|
|
export interface ProjectCardProps {
|
|
/** Project data */
|
|
project: ProjectListItem;
|
|
/** Called when card is clicked */
|
|
onClick?: () => void;
|
|
/** Called when action menu item is selected */
|
|
onAction?: (action: 'archive' | 'pause' | 'resume' | 'delete') => void;
|
|
/** Additional CSS classes */
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Complexity indicator dots
|
|
*/
|
|
function ComplexityIndicator({ complexity }: { complexity: 'low' | 'medium' | 'high' }) {
|
|
const levels = { low: 1, medium: 2, high: 3 };
|
|
const level = levels[complexity];
|
|
|
|
return (
|
|
<div className="flex items-center gap-0.5" title={`${complexity} complexity`}>
|
|
{[1, 2, 3].map((i) => (
|
|
<div
|
|
key={i}
|
|
className={cn(
|
|
'h-1.5 w-1.5 rounded-full',
|
|
i <= level ? 'bg-primary' : 'bg-muted'
|
|
)}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Loading skeleton for project cards
|
|
*/
|
|
export function ProjectCardSkeleton() {
|
|
return (
|
|
<Card className="h-[220px]">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<Skeleton className="h-5 w-20" />
|
|
<Skeleton className="h-5 w-16" />
|
|
</div>
|
|
<Skeleton className="mt-2 h-6 w-3/4" />
|
|
<Skeleton className="mt-2 h-4 w-full" />
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<Skeleton className="h-2 w-full" />
|
|
<div className="flex flex-wrap gap-1">
|
|
<Skeleton className="h-5 w-16" />
|
|
<Skeleton className="h-5 w-20" />
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<Skeleton className="h-4 w-20" />
|
|
<Skeleton className="h-4 w-24" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function ProjectCard({ project, onClick, onAction, className }: ProjectCardProps) {
|
|
return (
|
|
<Card
|
|
className={cn(
|
|
'group h-full cursor-pointer transition-all hover:border-primary hover:shadow-md',
|
|
className
|
|
)}
|
|
onClick={onClick}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
/* istanbul ignore next -- keyboard handler */
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onClick?.();
|
|
}
|
|
}}
|
|
>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<ProjectStatusBadge status={project.status} />
|
|
<div className="flex items-center gap-2">
|
|
<ComplexityIndicator complexity={project.complexity} />
|
|
{onAction && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100"
|
|
>
|
|
<MoreVertical className="h-4 w-4" />
|
|
<span className="sr-only">Project actions</span>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
|
{project.status === 'paused' ? (
|
|
<DropdownMenuItem onClick={() => onAction('resume')}>
|
|
<Play className="mr-2 h-4 w-4" />
|
|
Resume Project
|
|
</DropdownMenuItem>
|
|
) : project.status === 'active' ? (
|
|
<DropdownMenuItem onClick={() => onAction('pause')}>
|
|
<Pause className="mr-2 h-4 w-4" />
|
|
Pause Project
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
<DropdownMenuItem onClick={() => onAction('archive')}>
|
|
<Archive className="mr-2 h-4 w-4" />
|
|
Archive Project
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="text-destructive"
|
|
onClick={() => onAction('delete')}
|
|
>
|
|
Delete Project
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<CardTitle className="mt-2 line-clamp-1 text-lg">{project.name}</CardTitle>
|
|
|
|
{project.description && (
|
|
<p className="line-clamp-2 text-sm text-muted-foreground">{project.description}</p>
|
|
)}
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-3">
|
|
<ProgressBar value={project.progress} size="sm" showLabel />
|
|
|
|
{project.tags && project.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{project.tags.slice(0, 3).map((tag) => (
|
|
<Badge key={tag} variant="secondary" className="text-xs">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
{project.tags.length > 3 && (
|
|
<Badge variant="outline" className="text-xs">
|
|
+{project.tags.length - 3}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-3">
|
|
<span className="flex items-center gap-1">
|
|
<Bot className="h-3 w-3" />
|
|
{project.activeAgents}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<CircleDot className="h-3 w-3" />
|
|
{project.openIssues}
|
|
</span>
|
|
</div>
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{project.lastActivity}
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|