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

View File

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

View File

@ -4,13 +4,13 @@ import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { Project } from '@/types'; import type { Project } from '@/types';
import { import {
Dialog, Sheet,
DialogContent, SheetContent,
DialogHeader, SheetHeader,
DialogTitle, SheetTitle,
DialogFooter, SheetFooter,
DialogClose, SheetClose,
} from '@/components/ui/dialog'; } from '@/components/ui/sheet';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select'; import { Select } from '@/components/ui/select';
@ -29,7 +29,7 @@ export default function ProjectForm({ project, onClose }: ProjectFormProps) {
description: project?.description || '', description: project?.description || '',
status: project?.status || 'not_started', status: project?.status || 'not_started',
color: project?.color || '', color: project?.color || '',
due_date: project?.due_date || '', due_date: project?.due_date ? project.due_date.slice(0, 10) : '',
}); });
const mutation = useMutation({ 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) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
mutation.mutate(formData); mutation.mutate(formData);
}; };
return ( return (
<Dialog open={true} onOpenChange={onClose}> <Sheet open={true} onOpenChange={onClose}>
<DialogContent> <SheetContent>
<DialogClose onClick={onClose} /> <SheetClose onClick={onClose} />
<DialogHeader> <SheetHeader>
<DialogTitle>{project ? 'Edit Project' : 'New Project'}</DialogTitle> <SheetTitle>{project ? 'Edit Project' : 'New Project'}</SheetTitle>
</DialogHeader> </SheetHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
<div className="space-y-2"> <div className="flex-1 overflow-y-auto px-6 py-5 space-y-4">
<Label htmlFor="name">Name</Label> <div className="space-y-2">
<Input <Label htmlFor="name">Name</Label>
id="name" <Input
value={formData.name} id="name"
onChange={(e) => setFormData({ ...formData, name: e.target.value })} value={formData.name}
required onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/> required
</div> />
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<Textarea <Textarea
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4} rows={4}
/> />
</div> </div>
<div className="space-y-2"> <div className="grid grid-cols-2 gap-4">
<Label htmlFor="status">Status</Label> <div className="space-y-2">
<Select <Label htmlFor="status">Status</Label>
id="status" <Select
value={formData.status} id="status"
onChange={(e) => setFormData({ ...formData, status: e.target.value as Project['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="not_started">Not Started</option>
<option value="completed">Completed</option> <option value="in_progress">In Progress</option>
</Select> <option value="completed">Completed</option>
</div> </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"> <div className="space-y-2">
<Label htmlFor="color">Color</Label> <Label htmlFor="color">Color</Label>
<Input <Input
id="color" id="color"
type="color" type="color"
value={formData.color} value={formData.color || '#3b82f6'}
onChange={(e) => setFormData({ ...formData, color: e.target.value })} onChange={(e) => setFormData({ ...formData, color: e.target.value })}
/> />
</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> </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}> <Button type="button" variant="outline" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={mutation.isPending}> <Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : project ? 'Update' : 'Create'} {mutation.isPending ? 'Saving...' : project ? 'Update' : 'Create'}
</Button> </Button>
</DialogFooter> </SheetFooter>
</form> </form>
</DialogContent> </SheetContent>
</Dialog> </Sheet>
); );
} }

View File

@ -1,16 +1,22 @@
import { useState } from 'react'; 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 { useQuery } from '@tanstack/react-query';
import api from '@/lib/api'; import api from '@/lib/api';
import type { Project } from '@/types'; import type { Project } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select'; import { Card, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { GridSkeleton } from '@/components/ui/skeleton'; import { GridSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import ProjectCard from './ProjectCard'; import ProjectCard from './ProjectCard';
import ProjectForm from './ProjectForm'; 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() { export default function ProjectsPage() {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null); const [editingProject, setEditingProject] = useState<Project | null>(null);
@ -28,6 +34,9 @@ export default function ProjectsPage() {
? projects.filter((p) => p.status === statusFilter) ? projects.filter((p) => p.status === statusFilter)
: projects; : projects;
const inProgressCount = projects.filter((p) => p.status === 'in_progress').length;
const completedCount = projects.filter((p) => p.status === 'completed').length;
const handleEdit = (project: Project) => { const handleEdit = (project: Project) => {
setEditingProject(project); setEditingProject(project);
setShowForm(true); setShowForm(true);
@ -40,31 +49,78 @@ export default function ProjectsPage() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4"> {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="border-b bg-card px-6 h-16 flex items-center gap-4 shrink-0">
<h1 className="text-3xl font-bold">Projects</h1> <h1 className="font-heading text-2xl font-bold tracking-tight">Projects</h1>
<Button onClick={() => setShowForm(true)}>
<Plus className="mr-2 h-4 w-4" /> <div className="flex items-center rounded-md border border-border overflow-hidden ml-4">
New Project {statusFilters.map((sf) => (
</Button> <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>
<div className="flex items-center gap-4"> <div className="flex-1" />
<Label htmlFor="status-filter">Filter by status:</Label>
<Select <Button onClick={() => setShowForm(true)} size="sm">
id="status-filter" <Plus className="mr-2 h-4 w-4" />
value={statusFilter} New Project
onChange={(e) => setStatusFilter(e.target.value)} </Button>
>
<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> </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 ? ( {isLoading ? (
<GridSkeleton cards={6} /> <GridSkeleton cards={6} />
) : filteredProjects.length === 0 ? ( ) : filteredProjects.length === 0 ? (

View File

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