Add dark-themed FullCalendar "+more" popover with CSS X close button (replaces broken font icon). Add pr-8 to all mobile Select dropdowns to prevent text clipping under chevron. Normalize header gap to gap-2 md:gap-4 across all page headers for tighter mobile layout. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
186 lines
7.4 KiB
TypeScript
186 lines
7.4 KiB
TypeScript
import { useState, useMemo, useEffect } from 'react';
|
|
import { useLocation } from 'react-router-dom';
|
|
import { Plus, FolderKanban, Layers, PlayCircle, CheckCircle2, Search } from 'lucide-react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import api from '@/lib/api';
|
|
import type { Project } from '@/types';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Select } from '@/components/ui/select';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { GridSkeleton } from '@/components/ui/skeleton';
|
|
import { EmptyState } from '@/components/ui/empty-state';
|
|
import ProjectCard from './ProjectCard';
|
|
import ProjectForm from './ProjectForm';
|
|
|
|
const statusFilters = [
|
|
{ value: '', label: 'All' },
|
|
{ value: 'not_started', label: 'Not Started' },
|
|
{ value: 'in_progress', label: 'In Progress' },
|
|
{ value: 'completed', label: 'Completed' },
|
|
] as const;
|
|
|
|
export default function ProjectsPage() {
|
|
const location = useLocation();
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
|
const [statusFilter, setStatusFilter] = useState('');
|
|
const [search, setSearch] = useState('');
|
|
|
|
// Handle navigation state from dashboard
|
|
useEffect(() => {
|
|
const state = location.state as { filter?: string } | null;
|
|
if (state?.filter) {
|
|
setStatusFilter(state.filter);
|
|
window.history.replaceState({}, '');
|
|
}
|
|
}, [location.state]);
|
|
|
|
const { data: projects = [], isLoading } = useQuery({
|
|
queryKey: ['projects'],
|
|
queryFn: async () => {
|
|
const { data } = await api.get<Project[]>('/projects');
|
|
return data;
|
|
},
|
|
});
|
|
|
|
const filteredProjects = useMemo(() => {
|
|
let list = statusFilter ? projects.filter((p) => p.status === statusFilter) : projects;
|
|
if (search) {
|
|
const q = search.toLowerCase();
|
|
list = list.filter(
|
|
(p) => p.name.toLowerCase().includes(q) || p.description?.toLowerCase().includes(q)
|
|
);
|
|
}
|
|
return list;
|
|
}, [projects, statusFilter, search]);
|
|
|
|
const inProgressCount = projects.filter((p) => p.status === 'in_progress').length;
|
|
const completedCount = projects.filter((p) => p.status === 'completed').length;
|
|
|
|
const handleEdit = (project: Project) => {
|
|
setEditingProject(project);
|
|
setShowForm(true);
|
|
};
|
|
|
|
const handleCloseForm = () => {
|
|
setShowForm(false);
|
|
setEditingProject(null);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full animate-fade-in">
|
|
{/* Header */}
|
|
<div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
|
|
<h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight">Projects</h1>
|
|
|
|
<Select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
|
className="h-8 text-sm w-auto pr-8 md:hidden"
|
|
>
|
|
{statusFilters.map((sf) => (
|
|
<option key={sf.value} value={sf.value}>{sf.label}</option>
|
|
))}
|
|
</Select>
|
|
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden ml-4">
|
|
{statusFilters.map((sf) => (
|
|
<button
|
|
key={sf.value}
|
|
onClick={() => setStatusFilter(sf.value)}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
|
statusFilter === sf.value
|
|
? 'bg-accent/15 text-accent'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
|
}`}
|
|
style={{
|
|
backgroundColor: statusFilter === sf.value ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
|
color: statusFilter === sf.value ? 'hsl(var(--accent-color))' : undefined,
|
|
}}
|
|
>
|
|
{sf.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex-1" />
|
|
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-32 sm:w-52 h-8 pl-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<Button onClick={() => setShowForm(true)} size="sm">
|
|
<Plus className="h-4 w-4 md:mr-2" /><span className="hidden md:inline">New Project</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-5">
|
|
{/* Summary stats */}
|
|
{!isLoading && projects.length > 0 && (
|
|
<div className="grid gap-1.5 md:gap-2.5 grid-cols-3 mb-5">
|
|
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
|
<CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
|
|
<div className="p-1.5 rounded-md bg-blue-500/10 hidden sm:block">
|
|
<Layers className="h-4 w-4 text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">Total</p>
|
|
<p className="font-heading text-lg md:text-xl font-bold tabular-nums">{projects.length}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
|
<CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
|
|
<div className="p-1.5 rounded-md bg-purple-500/10 hidden sm:block">
|
|
<PlayCircle className="h-4 w-4 text-purple-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">In Progress</p>
|
|
<p className="font-heading text-lg md:text-xl font-bold tabular-nums">{inProgressCount}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
|
<CardContent className="p-2.5 md:p-4 flex items-center gap-2 md:gap-3">
|
|
<div className="p-1.5 rounded-md bg-green-500/10 hidden sm:block">
|
|
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-[9px] md:text-[10px] tracking-wider uppercase text-muted-foreground">Completed</p>
|
|
<p className="font-heading text-lg md:text-xl font-bold tabular-nums">{completedCount}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{isLoading ? (
|
|
<GridSkeleton cards={6} />
|
|
) : filteredProjects.length === 0 ? (
|
|
<EmptyState
|
|
icon={FolderKanban}
|
|
title="No projects yet"
|
|
description="Create your first project to start tracking tasks and progress."
|
|
actionLabel="New Project"
|
|
onAction={() => setShowForm(true)}
|
|
/>
|
|
) : (
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{filteredProjects.map((project) => (
|
|
<ProjectCard key={project.id} project={project} onEdit={handleEdit} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showForm && <ProjectForm project={editingProject} onClose={handleCloseForm} />}
|
|
</div>
|
|
);
|
|
}
|