diff --git a/frontend/src/components/projects/ProjectCard.tsx b/frontend/src/components/projects/ProjectCard.tsx index a4a5455..b50c9ee 100644 --- a/frontend/src/components/projects/ProjectCard.tsx +++ b/frontend/src/components/projects/ProjectCard.tsx @@ -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 = { + 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 = { + 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 ( navigate(`/projects/${project.id}`)} > -
- {project.name} - {project.status.replace('_', ' ')} +
+ {project.name} + + {statusLabels[project.status]} +
- {project.description && ( - {project.description} - )} + + {project.description || No description} + {totalTasks > 0 && (
Progress - + {completedTasks}/{totalTasks} tasks
)} {project.due_date && ( -
+
- Due {format(new Date(project.due_date), 'MMM d, yyyy')} + Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
)} diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index 0ad60a8..401fc47 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -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 = { + 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 = { + not_started: 'Not Started', + in_progress: 'In Progress', + completed: 'Completed', }; const taskStatusColors: Record = { - 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 = { - 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 (
-
-
- -

Loading...

-
+
+ +

Loading...

-
+
@@ -126,8 +131,17 @@ export default function ProjectDetail() { return
Project not found
; } - // 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 (
-
-
- -

{project.name}

- {project.status.replace('_', ' ')} - - -
- {project.description && ( -

{project.description}

- )} - +

+ {project.name} +

+ + {statusLabels[project.status]} + + +
-
+
+ {/* Description */} + {project.description && ( +

{project.description}

+ )} + + {/* Project Summary Card */} + + +
+ {/* Progress section */} +
+
+ Overall Progress + + {Math.round(progressPercent)}% + +
+
+
+
+

+ {completedTasks} of {totalTasks} tasks completed +

+
+ + {/* Divider */} +
+ + {/* Mini stats */} +
+
+
+ +
+

{totalTasks}

+

Total

+
+
+
+ +
+

{inProgressTasks}

+

Active

+
+
+
+ +
+

{completedTasks}

+

Done

+
+ {overdueTasks > 0 && ( +
+
+ +
+

{overdueTasks}

+

Overdue

+
+ )} +
+
+ + {/* Due date */} + {project.due_date && ( +
+ + Due {format(parseISO(project.due_date), 'MMM d, yyyy')} + {isProjectOverdue && (Overdue)} +
+ )} + + + + {/* Task list header */} +
+

Tasks

+ +
+ + {/* Task list */} {topLevelTasks.length === 0 ? ( - - -
- {/* Expand/collapse chevron */} - +
+ {/* Expand/collapse chevron */} + - - toggleTaskMutation.mutate({ taskId: task.id, status: task.status }) - } - disabled={toggleTaskMutation.isPending} - className="mt-1" - /> -
- {task.title} - {task.description && ( - {task.description} - )} -
- - {task.status.replace('_', ' ')} - - {task.priority} -
+ + toggleTaskMutation.mutate({ taskId: task.id, status: task.status }) + } + disabled={toggleTaskMutation.isPending} + className="mt-0.5" + /> - {/* Subtask progress bar */} - {progress && ( -
-
- Subtasks - - {progress.completed}/{progress.total} - -
-
-
-
-
- )} -
- - {/* Add subtask */} - - - +
+

+ {task.title} +

+ {task.description && ( +

{task.description}

+ )} +
+ + {task.status.replace('_', ' ')} + + + {task.priority} + + {task.due_date && ( + + {format(parseISO(task.due_date), 'MMM d')} + + )}
- - + + {/* Subtask progress bar */} + {progress && ( +
+
+ Subtasks + + {progress.completed}/{progress.total} + +
+
+
+
+
+ )} +
+ + {/* Actions */} +
+ + + +
+
{/* Subtasks - shown when expanded */} {isExpanded && hasSubtasks && (
{task.subtasks.map((subtask) => ( - - -
- - toggleTaskMutation.mutate({ - taskId: subtask.id, - status: subtask.status, - }) - } - disabled={toggleTaskMutation.isPending} - className="mt-0.5" - /> -
- - {subtask.title} - - {subtask.description && ( - - {subtask.description} - - )} -
- - {subtask.status.replace('_', ' ')} - - - {subtask.priority} - -
-
- - +
+ + toggleTaskMutation.mutate({ + taskId: subtask.id, + status: subtask.status, + }) + } + disabled={toggleTaskMutation.isPending} + className="mt-0.5" + /> +
+

+ {subtask.title} +

+ {subtask.description && ( +

{subtask.description}

+ )} +
+ + {subtask.status.replace('_', ' ')} + + + {subtask.priority} +
- - +
+
+ + +
+
))}
)} diff --git a/frontend/src/components/projects/ProjectForm.tsx b/frontend/src/components/projects/ProjectForm.tsx index f459970..2d32fa3 100644 --- a/frontend/src/components/projects/ProjectForm.tsx +++ b/frontend/src/components/projects/ProjectForm.tsx @@ -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 ( - - - - - {project ? 'Edit Project' : 'New Project'} - -
-
- - setFormData({ ...formData, name: e.target.value })} - required - /> -
+ + + + + {project ? 'Edit Project' : 'New Project'} + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
-
- -