UMBRA/frontend/src/components/projects/ProjectsPage.tsx
Kyle Pope 56175aaf86 Fix calendar popover, dropdown clipping, and header spacing across all tabs
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>
2026-03-11 02:13:41 +08:00

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>
);
}