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:
parent
bad1332d1b
commit
d4ba98c6eb
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user