forked from cardosofelipe/fast-next-template
feat(frontend): add Projects list page and components for #54
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>
This commit is contained in:
145
frontend/src/app/[locale]/(authenticated)/projects/page.tsx
Normal file
145
frontend/src/app/[locale]/(authenticated)/projects/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Projects List Page
|
||||||
|
*
|
||||||
|
* Displays all projects with filtering, sorting, and search.
|
||||||
|
* Supports grid and list view modes.
|
||||||
|
*
|
||||||
|
* @see Issue #54
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Link } from '@/lib/i18n/routing';
|
||||||
|
import { ProjectFilters, ProjectsGrid } from '@/components/projects';
|
||||||
|
import type { ViewMode, SortBy, SortOrder, Complexity } from '@/components/projects';
|
||||||
|
import type { ProjectStatus } from '@/components/projects/types';
|
||||||
|
import { useProjects, type ProjectListItem } from '@/lib/api/hooks/useProjects';
|
||||||
|
import { useDebounce } from '@/lib/hooks/useDebounce';
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<ProjectStatus | 'all'>('all');
|
||||||
|
const [complexityFilter, setComplexityFilter] = useState<Complexity>('all');
|
||||||
|
const [sortBy, setSortBy] = useState<SortBy>('recent');
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
|
|
||||||
|
// Debounce search for API calls
|
||||||
|
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||||
|
|
||||||
|
// Fetch projects
|
||||||
|
const { data, isLoading, error } = useProjects({
|
||||||
|
search: debouncedSearch || undefined,
|
||||||
|
status: statusFilter,
|
||||||
|
complexity: complexityFilter !== 'all' ? complexityFilter : undefined,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if any filters are active (for empty state message)
|
||||||
|
const hasFilters = useMemo(() => {
|
||||||
|
return searchQuery !== '' || statusFilter !== 'all' || complexityFilter !== 'all';
|
||||||
|
}, [searchQuery, statusFilter, complexityFilter]);
|
||||||
|
|
||||||
|
// Handle project card click
|
||||||
|
const handleProjectClick = useCallback(
|
||||||
|
(project: ProjectListItem) => {
|
||||||
|
router.push(`/projects/${project.id}`);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle project action
|
||||||
|
const handleProjectAction = useCallback(
|
||||||
|
(project: ProjectListItem, action: 'archive' | 'pause' | 'resume' | 'delete') => {
|
||||||
|
// TODO: Implement actual API calls
|
||||||
|
switch (action) {
|
||||||
|
case 'archive':
|
||||||
|
toast.success(`Archived: ${project.name}`);
|
||||||
|
break;
|
||||||
|
case 'pause':
|
||||||
|
toast.info(`Paused: ${project.name}`);
|
||||||
|
break;
|
||||||
|
case 'resume':
|
||||||
|
toast.success(`Resumed: ${project.name}`);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
toast.error(`Deleted: ${project.name}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show error toast if fetch fails
|
||||||
|
if (error) {
|
||||||
|
toast.error('Failed to load projects', {
|
||||||
|
description: 'Please try again later',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Projects</h1>
|
||||||
|
<p className="text-muted-foreground">Manage and monitor your projects</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/projects/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Project
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<ProjectFilters
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onStatusFilterChange={setStatusFilter}
|
||||||
|
complexityFilter={complexityFilter}
|
||||||
|
onComplexityFilterChange={setComplexityFilter}
|
||||||
|
sortBy={sortBy}
|
||||||
|
onSortByChange={setSortBy}
|
||||||
|
sortOrder={sortOrder}
|
||||||
|
onSortOrderChange={setSortOrder}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
|
className="mb-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Projects Grid */}
|
||||||
|
<ProjectsGrid
|
||||||
|
projects={data?.data ?? []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onProjectClick={handleProjectClick}
|
||||||
|
onProjectAction={handleProjectAction}
|
||||||
|
hasFilters={hasFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination - TODO: Add when more than 50 projects */}
|
||||||
|
{data && data.pagination.totalPages > 1 && (
|
||||||
|
<div className="mt-6 flex items-center justify-between text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Showing {data.data.length} of {data.pagination.total} projects
|
||||||
|
</span>
|
||||||
|
{/* TODO: Add pagination controls */}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
frontend/src/components/projects/ProjectCard.tsx
Normal file
199
frontend/src/components/projects/ProjectCard.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
frontend/src/components/projects/ProjectFilters.tsx
Normal file
272
frontend/src/components/projects/ProjectFilters.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* ProjectFilters Component
|
||||||
|
*
|
||||||
|
* Filter controls for the projects list including:
|
||||||
|
* - Search input with debounce
|
||||||
|
* - Status filter
|
||||||
|
* - Extended filters (complexity, etc.)
|
||||||
|
* - Sort controls
|
||||||
|
* - View mode toggle (grid/list)
|
||||||
|
*
|
||||||
|
* @see Issue #54
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
ChevronDown,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ProjectStatus } from './types';
|
||||||
|
|
||||||
|
export type ViewMode = 'grid' | 'list';
|
||||||
|
export type SortBy = 'recent' | 'name' | 'progress' | 'issues';
|
||||||
|
export type SortOrder = 'asc' | 'desc';
|
||||||
|
export type Complexity = 'low' | 'medium' | 'high' | 'all';
|
||||||
|
|
||||||
|
export interface ProjectFiltersProps {
|
||||||
|
/** Current search query */
|
||||||
|
searchQuery: string;
|
||||||
|
/** Called when search changes */
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
/** Current status filter */
|
||||||
|
statusFilter: ProjectStatus | 'all';
|
||||||
|
/** Called when status filter changes */
|
||||||
|
onStatusFilterChange: (status: ProjectStatus | 'all') => void;
|
||||||
|
/** Current complexity filter */
|
||||||
|
complexityFilter: Complexity;
|
||||||
|
/** Called when complexity filter changes */
|
||||||
|
onComplexityFilterChange: (complexity: Complexity) => void;
|
||||||
|
/** Current sort field */
|
||||||
|
sortBy: SortBy;
|
||||||
|
/** Called when sort field changes */
|
||||||
|
onSortByChange: (sortBy: SortBy) => void;
|
||||||
|
/** Current sort order */
|
||||||
|
sortOrder: SortOrder;
|
||||||
|
/** Called when sort order changes */
|
||||||
|
onSortOrderChange: (order: SortOrder) => void;
|
||||||
|
/** Current view mode */
|
||||||
|
viewMode: ViewMode;
|
||||||
|
/** Called when view mode changes */
|
||||||
|
onViewModeChange: (mode: ViewMode) => void;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptions: { value: ProjectStatus | 'all'; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'All Status' },
|
||||||
|
{ value: 'active', label: 'Active' },
|
||||||
|
{ value: 'paused', label: 'Paused' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
{ value: 'archived', label: 'Archived' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const complexityOptions: { value: Complexity; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'All Complexity' },
|
||||||
|
{ value: 'low', label: 'Low' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'high', label: 'High' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sortOptions: { value: SortBy; label: string }[] = [
|
||||||
|
{ value: 'recent', label: 'Most Recent' },
|
||||||
|
{ value: 'name', label: 'Name' },
|
||||||
|
{ value: 'progress', label: 'Progress' },
|
||||||
|
{ value: 'issues', label: 'Issues' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ProjectFilters({
|
||||||
|
searchQuery,
|
||||||
|
onSearchChange,
|
||||||
|
statusFilter,
|
||||||
|
onStatusFilterChange,
|
||||||
|
complexityFilter,
|
||||||
|
onComplexityFilterChange,
|
||||||
|
sortBy,
|
||||||
|
onSortByChange,
|
||||||
|
sortOrder,
|
||||||
|
onSortOrderChange,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
|
className,
|
||||||
|
}: ProjectFiltersProps) {
|
||||||
|
const [showExtended, setShowExtended] = useState(false);
|
||||||
|
|
||||||
|
// Check if any filters are active
|
||||||
|
const hasActiveFilters =
|
||||||
|
statusFilter !== 'all' || complexityFilter !== 'all' || searchQuery !== '';
|
||||||
|
|
||||||
|
// Count active filters for badge
|
||||||
|
const activeFilterCount =
|
||||||
|
(statusFilter !== 'all' ? 1 : 0) +
|
||||||
|
(complexityFilter !== 'all' ? 1 : 0) +
|
||||||
|
(searchQuery !== '' ? 1 : 0);
|
||||||
|
|
||||||
|
const clearAllFilters = () => {
|
||||||
|
onSearchChange('');
|
||||||
|
onStatusFilterChange('all');
|
||||||
|
onComplexityFilterChange('all');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{/* Quick Filters Row */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search projects..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
aria-label="Search projects"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(value) => onStatusFilterChange(value as ProjectStatus | 'all')}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-40" aria-label="Filter by status">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Extended Filters Toggle */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowExtended(!showExtended)}
|
||||||
|
className={cn(hasActiveFilters && 'border-primary')}
|
||||||
|
>
|
||||||
|
<Filter className="mr-2 h-4 w-4" />
|
||||||
|
Filters
|
||||||
|
{activeFilterCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-2">
|
||||||
|
{activeFilterCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<ChevronDown
|
||||||
|
className={cn('ml-2 h-4 w-4 transition-transform', showExtended && 'rotate-180')}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex rounded-lg border">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onViewModeChange('grid')}
|
||||||
|
aria-label="Grid view"
|
||||||
|
className="rounded-r-none"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onViewModeChange('list')}
|
||||||
|
aria-label="List view"
|
||||||
|
className="rounded-l-none"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Extended Filters */}
|
||||||
|
{showExtended && (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
|
{/* Complexity Filter */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="mb-2 block text-sm font-medium">Complexity</label>
|
||||||
|
<Select
|
||||||
|
value={complexityFilter}
|
||||||
|
onValueChange={(value) => onComplexityFilterChange(value as Complexity)}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="Filter by complexity">
|
||||||
|
<SelectValue placeholder="Complexity" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{complexityOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort By */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="mb-2 block text-sm font-medium">Sort By</label>
|
||||||
|
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as SortBy)}>
|
||||||
|
<SelectTrigger aria-label="Sort by">
|
||||||
|
<SelectValue placeholder="Sort by" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Order */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="mb-2 block text-sm font-medium">Order</label>
|
||||||
|
<Select
|
||||||
|
value={sortOrder}
|
||||||
|
onValueChange={(value) => onSortOrderChange(value as SortOrder)}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="Sort order">
|
||||||
|
<SelectValue placeholder="Order" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="desc">Descending</SelectItem>
|
||||||
|
<SelectItem value="asc">Ascending</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear Filters */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button variant="ghost" onClick={clearAllFilters} className="gap-2">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
Clear Filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
frontend/src/components/projects/ProjectsGrid.tsx
Normal file
119
frontend/src/components/projects/ProjectsGrid.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* ProjectsGrid Component
|
||||||
|
*
|
||||||
|
* Displays projects in either grid or list view with
|
||||||
|
* loading and empty states.
|
||||||
|
*
|
||||||
|
* @see Issue #54
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Folder, Plus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Link } from '@/lib/i18n/routing';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ProjectCard, ProjectCardSkeleton } from './ProjectCard';
|
||||||
|
import type { ProjectListItem } from '@/lib/api/hooks/useProjects';
|
||||||
|
import type { ViewMode } from './ProjectFilters';
|
||||||
|
|
||||||
|
export interface ProjectsGridProps {
|
||||||
|
/** Projects to display */
|
||||||
|
projects: ProjectListItem[];
|
||||||
|
/** Whether data is loading */
|
||||||
|
isLoading?: boolean;
|
||||||
|
/** Current view mode */
|
||||||
|
viewMode?: ViewMode;
|
||||||
|
/** Called when a project card is clicked */
|
||||||
|
onProjectClick?: (project: ProjectListItem) => void;
|
||||||
|
/** Called when a project action is selected */
|
||||||
|
onProjectAction?: (project: ProjectListItem, action: 'archive' | 'pause' | 'resume' | 'delete') => void;
|
||||||
|
/** Whether filters are currently applied (affects empty state message) */
|
||||||
|
hasFilters?: boolean;
|
||||||
|
/** Additional CSS classes */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state component
|
||||||
|
*/
|
||||||
|
function EmptyState({ hasFilters }: { hasFilters: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="py-16 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Folder className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">No projects found</h3>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
{hasFilters
|
||||||
|
? 'Try adjusting your filters or search query'
|
||||||
|
: 'Get started by creating your first project'}
|
||||||
|
</p>
|
||||||
|
{!hasFilters && (
|
||||||
|
<Button asChild className="mt-4">
|
||||||
|
<Link href="/projects/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Project
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading skeleton grid
|
||||||
|
*/
|
||||||
|
function LoadingSkeleton({ viewMode }: { viewMode: ViewMode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
|
||||||
|
: 'space-y-4'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||||
|
<ProjectCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectsGrid({
|
||||||
|
projects,
|
||||||
|
isLoading = false,
|
||||||
|
viewMode = 'grid',
|
||||||
|
onProjectClick,
|
||||||
|
onProjectAction,
|
||||||
|
hasFilters = false,
|
||||||
|
className,
|
||||||
|
}: ProjectsGridProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSkeleton viewMode={viewMode} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
return <EmptyState hasFilters={hasFilters} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
|
||||||
|
: 'space-y-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
onClick={() => onProjectClick?.(project)}
|
||||||
|
onAction={onProjectAction ? (action) => onProjectAction(project, action) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
frontend/src/lib/api/hooks/useProjects.ts
Normal file
271
frontend/src/lib/api/hooks/useProjects.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Projects List Hook
|
||||||
|
*
|
||||||
|
* Provides data for the projects list page with filtering,
|
||||||
|
* sorting, and pagination.
|
||||||
|
*
|
||||||
|
* Uses mock data until backend endpoints are available.
|
||||||
|
*
|
||||||
|
* @see Issue #54
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { ProjectStatus } from '@/components/projects/types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ProjectListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
status: ProjectStatus;
|
||||||
|
complexity: 'low' | 'medium' | 'high';
|
||||||
|
progress: number;
|
||||||
|
openIssues: number;
|
||||||
|
activeAgents: number;
|
||||||
|
currentSprint?: string;
|
||||||
|
lastActivity: string;
|
||||||
|
createdAt: string;
|
||||||
|
owner: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectsListParams {
|
||||||
|
search?: string;
|
||||||
|
status?: ProjectStatus | 'all';
|
||||||
|
complexity?: 'low' | 'medium' | 'high' | 'all';
|
||||||
|
sortBy?: 'recent' | 'name' | 'progress' | 'issues';
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectsListResponse {
|
||||||
|
data: ProjectListItem[];
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Mock Data
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const mockProjects: ProjectListItem[] = [
|
||||||
|
{
|
||||||
|
id: 'proj-001',
|
||||||
|
name: 'E-Commerce Platform Redesign',
|
||||||
|
description: 'Complete redesign of the e-commerce platform with modern UI/UX and improved checkout flow',
|
||||||
|
status: 'active',
|
||||||
|
complexity: 'high',
|
||||||
|
progress: 67,
|
||||||
|
openIssues: 12,
|
||||||
|
activeAgents: 4,
|
||||||
|
currentSprint: 'Sprint 3',
|
||||||
|
lastActivity: '2 minutes ago',
|
||||||
|
createdAt: '2025-11-15T10:00:00Z',
|
||||||
|
owner: { id: 'user-001', name: 'Felipe Cardoso' },
|
||||||
|
tags: ['e-commerce', 'frontend', 'ux'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proj-002',
|
||||||
|
name: 'Mobile Banking App',
|
||||||
|
description: 'Native mobile app for banking services with biometric authentication and real-time notifications',
|
||||||
|
status: 'active',
|
||||||
|
complexity: 'high',
|
||||||
|
progress: 45,
|
||||||
|
openIssues: 8,
|
||||||
|
activeAgents: 5,
|
||||||
|
currentSprint: 'Sprint 2',
|
||||||
|
lastActivity: '15 minutes ago',
|
||||||
|
createdAt: '2025-11-20T09:00:00Z',
|
||||||
|
owner: { id: 'user-001', name: 'Felipe Cardoso' },
|
||||||
|
tags: ['mobile', 'fintech', 'security'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proj-003',
|
||||||
|
name: 'Internal HR Portal',
|
||||||
|
description: 'Employee self-service portal for HR operations including leave requests and performance reviews',
|
||||||
|
status: 'paused',
|
||||||
|
complexity: 'medium',
|
||||||
|
progress: 23,
|
||||||
|
openIssues: 5,
|
||||||
|
activeAgents: 0,
|
||||||
|
currentSprint: 'Sprint 1',
|
||||||
|
lastActivity: '2 days ago',
|
||||||
|
createdAt: '2025-10-01T08:00:00Z',
|
||||||
|
owner: { id: 'user-002', name: 'Maria Santos' },
|
||||||
|
tags: ['internal', 'hr', 'portal'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proj-004',
|
||||||
|
name: 'API Gateway Modernization',
|
||||||
|
description: 'Migrate legacy API gateway to cloud-native architecture with improved rate limiting and caching',
|
||||||
|
status: 'active',
|
||||||
|
complexity: 'high',
|
||||||
|
progress: 82,
|
||||||
|
openIssues: 3,
|
||||||
|
activeAgents: 2,
|
||||||
|
currentSprint: 'Sprint 4',
|
||||||
|
lastActivity: '1 hour ago',
|
||||||
|
createdAt: '2025-12-01T11:00:00Z',
|
||||||
|
owner: { id: 'user-001', name: 'Felipe Cardoso' },
|
||||||
|
tags: ['api', 'cloud', 'infrastructure'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proj-005',
|
||||||
|
name: 'Customer Analytics Dashboard',
|
||||||
|
description: 'Real-time analytics dashboard for customer behavior insights with ML-powered predictions',
|
||||||
|
status: 'completed',
|
||||||
|
complexity: 'medium',
|
||||||
|
progress: 100,
|
||||||
|
openIssues: 0,
|
||||||
|
activeAgents: 0,
|
||||||
|
lastActivity: '2 weeks ago',
|
||||||
|
createdAt: '2025-09-01T10:00:00Z',
|
||||||
|
owner: { id: 'user-003', name: 'Alex Johnson' },
|
||||||
|
tags: ['analytics', 'ml', 'dashboard'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proj-006',
|
||||||
|
name: 'DevOps Pipeline Automation',
|
||||||
|
description: 'Automate CI/CD pipelines with AI-assisted deployments and rollback capabilities',
|
||||||
|
status: 'active',
|
||||||
|
complexity: 'medium',
|
||||||
|
progress: 35,
|
||||||
|
openIssues: 6,
|
||||||
|
activeAgents: 3,
|
||||||
|
currentSprint: 'Sprint 1',
|
||||||
|
lastActivity: '30 minutes ago',
|
||||||
|
createdAt: '2025-12-10T14:00:00Z',
|
||||||
|
owner: { id: 'user-001', name: 'Felipe Cardoso' },
|
||||||
|
tags: ['devops', 'automation', 'ci-cd'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proj-007',
|
||||||
|
name: 'Inventory Management System',
|
||||||
|
description: 'Warehouse inventory tracking with barcode scanning and automated reordering',
|
||||||
|
status: 'archived',
|
||||||
|
complexity: 'low',
|
||||||
|
progress: 100,
|
||||||
|
openIssues: 0,
|
||||||
|
activeAgents: 0,
|
||||||
|
lastActivity: '1 month ago',
|
||||||
|
createdAt: '2025-06-15T08:00:00Z',
|
||||||
|
owner: { id: 'user-002', name: 'Maria Santos' },
|
||||||
|
tags: ['inventory', 'warehouse', 'logistics'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proj-008',
|
||||||
|
name: 'Customer Support Chatbot',
|
||||||
|
description: 'AI-powered chatbot for 24/7 customer support with sentiment analysis',
|
||||||
|
status: 'active',
|
||||||
|
complexity: 'medium',
|
||||||
|
progress: 58,
|
||||||
|
openIssues: 4,
|
||||||
|
activeAgents: 2,
|
||||||
|
currentSprint: 'Sprint 2',
|
||||||
|
lastActivity: '45 minutes ago',
|
||||||
|
createdAt: '2025-12-05T09:00:00Z',
|
||||||
|
owner: { id: 'user-003', name: 'Alex Johnson' },
|
||||||
|
tags: ['ai', 'chatbot', 'support'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hook
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches projects list with filtering, sorting, and pagination
|
||||||
|
*/
|
||||||
|
export function useProjects(params: ProjectsListParams = {}) {
|
||||||
|
const {
|
||||||
|
search = '',
|
||||||
|
status = 'all',
|
||||||
|
complexity = 'all',
|
||||||
|
sortBy = 'recent',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
page = 1,
|
||||||
|
limit = 50,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
return useQuery<ProjectsListResponse>({
|
||||||
|
queryKey: ['projects', { search, status, complexity, sortBy, sortOrder, page, limit }],
|
||||||
|
queryFn: async () => {
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||||
|
|
||||||
|
// Filter projects
|
||||||
|
let filtered = [...mockProjects];
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (search) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(searchLower) ||
|
||||||
|
p.description?.toLowerCase().includes(searchLower) ||
|
||||||
|
p.tags?.some((t) => t.toLowerCase().includes(searchLower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if (status !== 'all') {
|
||||||
|
filtered = filtered.filter((p) => p.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complexity filter
|
||||||
|
if (complexity !== 'all') {
|
||||||
|
filtered = filtered.filter((p) => p.complexity === complexity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let comparison = 0;
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'name':
|
||||||
|
comparison = a.name.localeCompare(b.name);
|
||||||
|
break;
|
||||||
|
case 'progress':
|
||||||
|
comparison = a.progress - b.progress;
|
||||||
|
break;
|
||||||
|
case 'issues':
|
||||||
|
comparison = a.openIssues - b.openIssues;
|
||||||
|
break;
|
||||||
|
case 'recent':
|
||||||
|
default:
|
||||||
|
comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return sortOrder === 'asc' ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const total = filtered.length;
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
const end = start + limit;
|
||||||
|
const paginatedData = filtered.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: paginatedData,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
staleTime: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user