UI refresh: Projects pages (Phase 4)

- ProjectsPage: h-16 header, segmented pill filter, summary stat cards
- ProjectCard: sentence case, stylesheet status colors, hover glow, overdue dates
- ProjectDetail: summary progress card, compact task rows, overdue highlighting
- ProjectForm: Dialog → Sheet migration, 2-column layout, delete in footer
- TaskForm: Dialog → Sheet migration, 2-column grid layout, delete in footer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-22 02:56:07 +08:00
parent bad1332d1b
commit d4ba98c6eb
5 changed files with 621 additions and 381 deletions

View File

@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { format } from 'date-fns';
import { format, isPast, parseISO } from 'date-fns';
import { Calendar } from 'lucide-react';
import type { Project } from '@/types';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
@ -10,10 +10,16 @@ interface ProjectCardProps {
onEdit: (project: Project) => void;
}
const statusColors = {
not_started: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
in_progress: 'bg-accent/10 text-accent border-accent/20',
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
const statusColors: Record<string, string> = {
not_started: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
in_progress: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
};
const statusLabels: Record<string, string> = {
not_started: 'Not Started',
in_progress: 'In Progress',
completed: 'Completed',
};
export default function ProjectCard({ project }: ProjectCardProps) {
@ -23,41 +29,48 @@ export default function ProjectCard({ project }: ProjectCardProps) {
const totalTasks = project.tasks?.length || 0;
const progress = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0;
const isOverdue =
project.due_date &&
project.status !== 'completed' &&
isPast(parseISO(project.due_date));
return (
<Card
className="cursor-pointer transition-colors hover:bg-accent/5"
className="cursor-pointer hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200"
onClick={() => navigate(`/projects/${project.id}`)}
>
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="text-xl">{project.name}</CardTitle>
<Badge className={statusColors[project.status]}>{project.status.replace('_', ' ')}</Badge>
<div className="flex items-start justify-between gap-2">
<CardTitle className="font-heading text-lg font-semibold">{project.name}</CardTitle>
<Badge className={statusColors[project.status]}>
{statusLabels[project.status]}
</Badge>
</div>
{project.description && (
<CardDescription className="line-clamp-2">{project.description}</CardDescription>
)}
<CardDescription className="line-clamp-2">
{project.description || <span className="italic text-muted-foreground/50">No description</span>}
</CardDescription>
</CardHeader>
<CardContent>
{totalTasks > 0 && (
<div className="mb-3">
<div className="flex justify-between text-sm mb-1">
<span className="text-muted-foreground">Progress</span>
<span className="font-medium">
<span className="font-medium tabular-nums">
{completedTasks}/{totalTasks} tasks
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-accent transition-all"
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{project.due_date && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className={`flex items-center gap-2 text-sm ${isOverdue ? 'text-red-400' : 'text-muted-foreground'}`}>
<Calendar className="h-4 w-4" />
Due {format(new Date(project.due_date), 'MMM d, yyyy')}
Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
</div>
)}
</CardContent>

View File

@ -2,34 +2,44 @@ import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ArrowLeft, Plus, Trash2, ListChecks, ChevronRight, Pencil } from 'lucide-react';
import { format, isPast, parseISO } from 'date-fns';
import {
ArrowLeft, Plus, Trash2, ListChecks, ChevronRight, Pencil,
Calendar, CheckCircle2, PlayCircle, AlertTriangle,
} from 'lucide-react';
import api from '@/lib/api';
import type { Project, ProjectTask } from '@/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { ListSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import TaskForm from './TaskForm';
import ProjectForm from './ProjectForm';
const statusColors = {
not_started: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
in_progress: 'bg-accent/10 text-accent border-accent/20',
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
const statusColors: Record<string, string> = {
not_started: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
in_progress: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
};
const statusLabels: Record<string, string> = {
not_started: 'Not Started',
in_progress: 'In Progress',
completed: 'Completed',
};
const taskStatusColors: Record<string, string> = {
pending: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
in_progress: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
};
const priorityColors: Record<string, string> = {
low: 'bg-green-500/10 text-green-500 border-green-500/20',
medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
high: 'bg-red-500/10 text-red-500 border-red-500/20',
low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-red-500/20 text-red-400',
};
function getSubtaskProgress(task: ProjectTask) {
@ -52,11 +62,8 @@ export default function ProjectDetail() {
const toggleExpand = (taskId: number) => {
setExpandedTasks((prev) => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
if (next.has(taskId)) next.delete(taskId);
else next.add(taskId);
return next;
});
};
@ -107,15 +114,13 @@ export default function ProjectDetail() {
if (isLoading) {
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="text-3xl font-bold flex-1">Loading...</h1>
</div>
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-heading text-2xl font-bold tracking-tight flex-1">Loading...</h1>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex-1 overflow-y-auto px-6 py-5">
<ListSkeleton rows={4} />
</div>
</div>
@ -126,8 +131,17 @@ export default function ProjectDetail() {
return <div className="p-6 text-center text-muted-foreground">Project not found</div>;
}
// Filter to top-level tasks only (subtasks are nested inside their parent)
const topLevelTasks = project.tasks?.filter((t) => !t.parent_task_id) || [];
const allTasks = project.tasks || [];
const topLevelTasks = allTasks.filter((t) => !t.parent_task_id);
const completedTasks = allTasks.filter((t) => t.status === 'completed').length;
const inProgressTasks = allTasks.filter((t) => t.status === 'in_progress').length;
const overdueTasks = allTasks.filter(
(t) => t.due_date && t.status !== 'completed' && isPast(parseISO(t.due_date))
).length;
const totalTasks = allTasks.length;
const progressPercent = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0;
const isProjectOverdue =
project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date));
const openTaskForm = (task: ProjectTask | null, parentId: number | null) => {
setEditingTask(task);
@ -143,37 +157,124 @@ export default function ProjectDetail() {
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<div className="flex items-center gap-4 mb-4">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="text-3xl font-bold flex-1">{project.name}</h1>
<Badge className={statusColors[project.status]}>{project.status.replace('_', ' ')}</Badge>
<Button variant="outline" onClick={() => setShowProjectForm(true)}>
Edit Project
</Button>
<Button
variant="destructive"
onClick={() => {
if (!window.confirm('Delete this project and all its tasks?')) return;
deleteProjectMutation.mutate();
}}
disabled={deleteProjectMutation.isPending}
>
Delete Project
</Button>
</div>
{project.description && (
<p className="text-muted-foreground mb-4">{project.description}</p>
)}
<Button onClick={() => openTaskForm(null, null)}>
<Plus className="mr-2 h-4 w-4" />
Add Task
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-heading text-2xl font-bold tracking-tight flex-1 truncate">
{project.name}
</h1>
<Badge className={statusColors[project.status]}>
{statusLabels[project.status]}
</Badge>
<Button variant="outline" size="sm" onClick={() => setShowProjectForm(true)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (!window.confirm('Delete this project and all its tasks?')) return;
deleteProjectMutation.mutate();
}}
disabled={deleteProjectMutation.isPending}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</Button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
{/* Description */}
{project.description && (
<p className="text-sm text-muted-foreground">{project.description}</p>
)}
{/* Project Summary Card */}
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-5">
<div className="flex items-center gap-6">
{/* Progress section */}
<div className="flex-1">
<div className="flex items-baseline justify-between mb-2">
<span className="text-sm text-muted-foreground">Overall Progress</span>
<span className="font-heading text-lg font-bold tabular-nums">
{Math.round(progressPercent)}%
</span>
</div>
<div className="h-2.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1.5 tabular-nums">
{completedTasks} of {totalTasks} tasks completed
</p>
</div>
{/* Divider */}
<div className="w-px h-16 bg-border" />
{/* Mini stats */}
<div className="flex items-center gap-5">
<div className="text-center">
<div className="p-1.5 rounded-md bg-blue-500/10 mx-auto w-fit mb-1">
<ListChecks className="h-4 w-4 text-blue-400" />
</div>
<p className="font-heading text-lg font-bold tabular-nums">{totalTasks}</p>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Total</p>
</div>
<div className="text-center">
<div className="p-1.5 rounded-md bg-purple-500/10 mx-auto w-fit mb-1">
<PlayCircle className="h-4 w-4 text-purple-400" />
</div>
<p className="font-heading text-lg font-bold tabular-nums">{inProgressTasks}</p>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Active</p>
</div>
<div className="text-center">
<div className="p-1.5 rounded-md bg-green-500/10 mx-auto w-fit mb-1">
<CheckCircle2 className="h-4 w-4 text-green-400" />
</div>
<p className="font-heading text-lg font-bold tabular-nums">{completedTasks}</p>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Done</p>
</div>
{overdueTasks > 0 && (
<div className="text-center">
<div className="p-1.5 rounded-md bg-red-500/10 mx-auto w-fit mb-1">
<AlertTriangle className="h-4 w-4 text-red-400" />
</div>
<p className="font-heading text-lg font-bold tabular-nums text-red-400">{overdueTasks}</p>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Overdue</p>
</div>
)}
</div>
</div>
{/* Due date */}
{project.due_date && (
<div className={`flex items-center gap-2 text-sm mt-4 pt-3 border-t border-border ${isProjectOverdue ? 'text-red-400' : 'text-muted-foreground'}`}>
<Calendar className="h-4 w-4" />
Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
{isProjectOverdue && <span className="text-xs font-medium">(Overdue)</span>}
</div>
)}
</CardContent>
</Card>
{/* Task list header */}
<div className="flex items-center justify-between">
<h2 className="font-heading text-lg font-semibold">Tasks</h2>
<Button size="sm" onClick={() => openTaskForm(null, null)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Task
</Button>
</div>
{/* Task list */}
{topLevelTasks.length === 0 ? (
<EmptyState
icon={ListChecks}
@ -191,154 +292,162 @@ export default function ProjectDetail() {
return (
<div key={task.id}>
<Card>
<CardHeader>
<div className="flex items-start gap-3">
{/* Expand/collapse chevron */}
<button
onClick={() => hasSubtasks && toggleExpand(task.id)}
className={`mt-1 transition-colors ${hasSubtasks ? 'text-muted-foreground hover:text-foreground cursor-pointer' : 'text-transparent cursor-default'}`}
>
<ChevronRight
className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
<div className="flex items-start gap-3 p-3 rounded-lg border border-border bg-card hover:bg-card-elevated transition-colors duration-150">
{/* Expand/collapse chevron */}
<button
onClick={() => hasSubtasks && toggleExpand(task.id)}
className={`mt-0.5 transition-colors ${hasSubtasks ? 'text-muted-foreground hover:text-foreground cursor-pointer' : 'text-transparent cursor-default'}`}
>
<ChevronRight
className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
<Checkbox
checked={task.status === 'completed'}
onChange={() =>
toggleTaskMutation.mutate({ taskId: task.id, status: task.status })
}
disabled={toggleTaskMutation.isPending}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg">{task.title}</CardTitle>
{task.description && (
<CardDescription className="mt-1">{task.description}</CardDescription>
)}
<div className="flex items-center gap-2 mt-2">
<Badge className={taskStatusColors[task.status]}>
{task.status.replace('_', ' ')}
</Badge>
<Badge className={priorityColors[task.priority]}>{task.priority}</Badge>
</div>
<Checkbox
checked={task.status === 'completed'}
onChange={() =>
toggleTaskMutation.mutate({ taskId: task.id, status: task.status })
}
disabled={toggleTaskMutation.isPending}
className="mt-0.5"
/>
{/* Subtask progress bar */}
{progress && (
<div className="mt-3">
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Subtasks</span>
<span className="font-medium">
{progress.completed}/{progress.total}
</span>
</div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
)}
</div>
{/* Add subtask */}
<Button
variant="ghost"
size="icon"
onClick={() => openTaskForm(null, task.id)}
title="Add subtask"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openTaskForm(task, null)}
title="Edit task"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (!window.confirm('Delete this task and all its subtasks?')) return;
deleteTaskMutation.mutate(task.id);
}}
disabled={deleteTaskMutation.isPending}
title="Delete task"
>
<Trash2 className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<p className={`font-heading font-semibold text-sm ${task.status === 'completed' ? 'line-through text-muted-foreground' : ''}`}>
{task.title}
</p>
{task.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{task.description}</p>
)}
<div className="flex items-center gap-1.5 mt-1.5">
<Badge className={`text-[9px] px-1.5 py-0.5 ${taskStatusColors[task.status]}`}>
{task.status.replace('_', ' ')}
</Badge>
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority]}`}>
{task.priority}
</Badge>
{task.due_date && (
<span className={`text-[11px] ${task.status !== 'completed' && isPast(parseISO(task.due_date)) ? 'text-red-400' : 'text-muted-foreground'}`}>
{format(parseISO(task.due_date), 'MMM d')}
</span>
)}
</div>
</CardHeader>
</Card>
{/* Subtask progress bar */}
{progress && (
<div className="mt-2">
<div className="flex justify-between text-[11px] mb-0.5">
<span className="text-muted-foreground">Subtasks</span>
<span className="font-medium tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => openTaskForm(null, task.id)}
title="Add subtask"
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => openTaskForm(task, null)}
title="Edit task"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => {
if (!window.confirm('Delete this task and all its subtasks?')) return;
deleteTaskMutation.mutate(task.id);
}}
disabled={deleteTaskMutation.isPending}
title="Delete task"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Subtasks - shown when expanded */}
{isExpanded && hasSubtasks && (
<div className="ml-9 mt-1 space-y-1">
{task.subtasks.map((subtask) => (
<Card key={subtask.id} className="border-l-2 border-accent/30">
<CardHeader className="py-3 px-4">
<div className="flex items-start gap-3">
<Checkbox
checked={subtask.status === 'completed'}
onChange={() =>
toggleTaskMutation.mutate({
taskId: subtask.id,
status: subtask.status,
})
}
disabled={toggleTaskMutation.isPending}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<CardTitle className="text-sm font-medium">
{subtask.title}
</CardTitle>
{subtask.description && (
<CardDescription className="mt-0.5 text-xs">
{subtask.description}
</CardDescription>
)}
<div className="flex items-center gap-2 mt-1.5">
<Badge
className={`text-xs ${taskStatusColors[subtask.status]}`}
>
{subtask.status.replace('_', ' ')}
</Badge>
<Badge
className={`text-xs ${priorityColors[subtask.priority]}`}
>
{subtask.priority}
</Badge>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => openTaskForm(subtask, task.id)}
title="Edit subtask"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (!window.confirm('Delete this subtask?')) return;
deleteTaskMutation.mutate(subtask.id);
}}
disabled={deleteTaskMutation.isPending}
title="Delete subtask"
>
<Trash2 className="h-3 w-3" />
</Button>
<div
key={subtask.id}
className="flex items-start gap-3 p-2.5 rounded-md border-l-2 border-accent/30 bg-card hover:bg-card-elevated transition-colors duration-150"
>
<Checkbox
checked={subtask.status === 'completed'}
onChange={() =>
toggleTaskMutation.mutate({
taskId: subtask.id,
status: subtask.status,
})
}
disabled={toggleTaskMutation.isPending}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${subtask.status === 'completed' ? 'line-through text-muted-foreground' : ''}`}>
{subtask.title}
</p>
{subtask.description && (
<p className="text-xs text-muted-foreground mt-0.5">{subtask.description}</p>
)}
<div className="flex items-center gap-1.5 mt-1">
<Badge className={`text-[9px] px-1.5 py-0.5 ${taskStatusColors[subtask.status]}`}>
{subtask.status.replace('_', ' ')}
</Badge>
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[subtask.priority]}`}>
{subtask.priority}
</Badge>
</div>
</CardHeader>
</Card>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => openTaskForm(subtask, task.id)}
title="Edit subtask"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => {
if (!window.confirm('Delete this subtask?')) return;
deleteTaskMutation.mutate(subtask.id);
}}
disabled={deleteTaskMutation.isPending}
title="Delete subtask"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
)}

View File

@ -4,13 +4,13 @@ import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { Project } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
SheetClose,
} from '@/components/ui/sheet';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
@ -29,7 +29,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
description: project?.description || '',
status: project?.status || 'not_started',
color: project?.color || '',
due_date: project?.due_date || '',
due_date: project?.due_date ? project.due_date.slice(0, 10) : '',
});
const mutation = useMutation({
@ -55,84 +55,114 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/projects/${project!.id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
toast.success('Project deleted');
onClose();
},
onError: () => {
toast.error('Failed to delete project');
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{project ? 'Edit Project' : 'New Project'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<Sheet open={true} onOpenChange={onClose}>
<SheetContent>
<SheetClose onClick={onClose} />
<SheetHeader>
<SheetTitle>{project ? 'Edit Project' : 'New Project'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
id="status"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as Project['status'] })}
>
<option value="not_started">Not Started</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
id="status"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as Project['status'] })}
>
<option value="not_started">Not Started</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Input
id="color"
type="color"
value={formData.color}
value={formData.color || '#3b82f6'}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<SheetFooter>
{project && (
<Button
type="button"
variant="destructive"
className="mr-auto"
onClick={() => {
if (!window.confirm('Delete this project and all its tasks?')) return;
deleteMutation.mutate();
}}
disabled={deleteMutation.isPending}
>
Delete
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : project ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</SheetFooter>
</form>
</DialogContent>
</Dialog>
</SheetContent>
</Sheet>
);
}

View File

@ -1,16 +1,22 @@
import { useState } from 'react';
import { Plus, FolderKanban } from 'lucide-react';
import { Plus, FolderKanban, Layers, PlayCircle, CheckCircle2 } 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 { Label } from '@/components/ui/label';
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 [showForm, setShowForm] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
@ -28,6 +34,9 @@ export default function ProjectsPage() {
? projects.filter((p) => p.status === statusFilter)
: projects;
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);
@ -40,31 +49,78 @@ export default function ProjectsPage() {
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-3xl font-bold">Projects</h1>
<Button onClick={() => setShowForm(true)}>
<Plus className="mr-2 h-4 w-4" />
New Project
</Button>
{/* Header */}
<div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1>
<div className="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 items-center gap-4">
<Label htmlFor="status-filter">Filter by status:</Label>
<Select
id="status-filter"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="">All</option>
<option value="not_started">Not Started</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</Select>
</div>
<div className="flex-1" />
<Button onClick={() => setShowForm(true)} size="sm">
<Plus className="mr-2 h-4 w-4" />
New Project
</Button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex-1 overflow-y-auto px-6 py-5">
{/* Summary stats */}
{!isLoading && projects.length > 0 && (
<div className="grid gap-2.5 grid-cols-3 mb-5">
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4 flex items-center gap-3">
<div className="p-1.5 rounded-md bg-blue-500/10">
<Layers className="h-4 w-4 text-blue-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Total</p>
<p className="font-heading 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-4 flex items-center gap-3">
<div className="p-1.5 rounded-md bg-purple-500/10">
<PlayCircle className="h-4 w-4 text-purple-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">In Progress</p>
<p className="font-heading 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-4 flex items-center gap-3">
<div className="p-1.5 rounded-md bg-green-500/10">
<CheckCircle2 className="h-4 w-4 text-green-400" />
</div>
<div>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Completed</p>
<p className="font-heading text-xl font-bold tabular-nums">{completedCount}</p>
</div>
</CardContent>
</Card>
</div>
)}
{isLoading ? (
<GridSkeleton cards={6} />
) : filteredProjects.length === 0 ? (

View File

@ -4,13 +4,13 @@ import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api';
import type { ProjectTask, Person } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
SheetClose,
} from '@/components/ui/sheet';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
@ -31,7 +31,7 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas
description: task?.description || '',
status: task?.status || 'pending',
priority: task?.priority || 'medium',
due_date: task?.due_date || '',
due_date: task?.due_date ? task.due_date.slice(0, 10) : '',
person_id: task?.person_id?.toString() || '',
});
@ -70,103 +70,135 @@ export default function TaskForm({ projectId, task, parentTaskId, onClose }: Tas
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
await api.delete(`/projects/${projectId}/tasks/${task!.id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
toast.success('Task deleted');
onClose();
},
onError: () => {
toast.error('Failed to delete task');
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{task ? 'Edit Task' : parentTaskId ? 'New Subtask' : 'New Task'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Sheet open={true} onOpenChange={onClose}>
<SheetContent>
<SheetClose onClick={onClose} />
<SheetHeader>
<SheetTitle>{task ? 'Edit Task' : parentTaskId ? 'New Subtask' : 'New Task'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
id="status"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as ProjectTask['status'] })}
>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</Select>
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="priority">Priority</Label>
<Select
id="priority"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as ProjectTask['priority'] })}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
id="status"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as ProjectTask['status'] })}
>
<option value="pending">Pending</option>
<option value="in_progress">In Progress</option>
<option value="completed">Completed</option>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="priority">Priority</Label>
<Select
id="priority"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as ProjectTask['priority'] })}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="person">Assign To</Label>
<Select
id="person"
value={formData.person_id}
onChange={(e) => setFormData({ ...formData, person_id: e.target.value })}
>
<option value="">Unassigned</option>
{people.map((person) => (
<option key={person.id} value={person.id}>
{person.name}
</option>
))}
</Select>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="due_date">Due Date</Label>
<Input
id="due_date"
type="date"
value={formData.due_date}
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="person">Assign To</Label>
<Select
id="person"
value={formData.person_id}
onChange={(e) => setFormData({ ...formData, person_id: e.target.value })}
>
<option value="">Unassigned</option>
{people.map((person) => (
<option key={person.id} value={person.id}>
{person.name}
</option>
))}
</Select>
</div>
<DialogFooter>
<SheetFooter>
{task && (
<Button
type="button"
variant="destructive"
className="mr-auto"
onClick={() => {
if (!window.confirm('Delete this task?')) return;
deleteMutation.mutate();
}}
disabled={deleteMutation.isPending}
>
Delete
</Button>
)}
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : task ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</SheetFooter>
</form>
</DialogContent>
</Dialog>
</SheetContent>
</Sheet>
);
}