UMBRA/frontend/src/components/projects/ProjectDetail.tsx
Kyle Pope a737f06e85 Action deferred QA items: shared overlay, sort, touch, a11y
- S-01/W-06/S-02/S-04: Extract MobileDetailOverlay shared component
  with Escape key, body scroll lock, and ARIA dialog attributes.
  Refactored Todos, Reminders, People, Locations, ProjectDetail.
- W-02: Add specificity contract comment to mobile-scale CSS
- W-03: Enforce 10px floor for text-[9px] on mobile
- W-05: Add sort dropdown to EntityTable mobile card view
- S-03: Export MOBILE/DESKTOP breakpoint constants from useMediaQuery,
  updated all 8 consumer files to use constants
- S-06: Bump KanbanBoard TouchSensor tolerance from 5 to 8
- S-07: Hover state audit — no action needed, hoverOnlyWhenSupported
  in Tailwind config already handles touch devices correctly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 03:43:25 +08:00

704 lines
27 KiB
TypeScript

import { useState, useMemo, useCallback } from 'react';
import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, isPast, parseISO } from 'date-fns';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin,
Calendar, CheckCircle2, PlayCircle, AlertTriangle,
List, Columns3, ArrowUpDown,
} 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, CardContent } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import { ListSkeleton } from '@/components/ui/skeleton';
import { EmptyState } from '@/components/ui/empty-state';
import TaskRow from './TaskRow';
import TaskDetailPanel from './TaskDetailPanel';
import KanbanBoard from './KanbanBoard';
import TaskForm from './TaskForm';
import ProjectForm from './ProjectForm';
import { statusColors, statusLabels } from './constants';
import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay';
type SortMode = 'manual' | 'priority' | 'due_date';
type ViewMode = 'list' | 'kanban';
const PRIORITY_ORDER: Record<string, number> = { high: 0, medium: 1, low: 2, none: 3 };
function SortableTaskRow({
task,
isSelected,
isExpanded,
showDragHandle,
onSelect,
onToggleExpand,
onToggleStatus,
togglePending,
}: {
task: ProjectTask;
isSelected: boolean;
isExpanded: boolean;
showDragHandle: boolean;
onSelect: () => void;
onToggleExpand: () => void;
onToggleStatus: () => void;
togglePending: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<TaskRow
task={task}
isSelected={isSelected}
isExpanded={isExpanded}
showDragHandle={showDragHandle}
onSelect={onSelect}
onToggleExpand={onToggleExpand}
onToggleStatus={onToggleStatus}
togglePending={togglePending}
dragHandleProps={listeners}
/>
</div>
);
}
export default function ProjectDetail() {
const { id } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showTaskForm, setShowTaskForm] = useState(false);
const [showProjectForm, setShowProjectForm] = useState(false);
const [editingTask, setEditingTask] = useState<ProjectTask | null>(null);
const [subtaskParentId, setSubtaskParentId] = useState<number | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null);
const [kanbanParentTaskId, setKanbanParentTaskId] = useState<number | null>(null);
const [sortMode, setSortMode] = useState<SortMode>('manual');
const [viewMode, setViewMode] = useState<ViewMode>('list');
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor)
);
const toggleExpand = (taskId: number) => {
setExpandedTasks((prev) => {
const next = new Set(prev);
if (next.has(taskId)) next.delete(taskId);
else next.add(taskId);
return next;
});
};
const { data: project, isLoading } = useQuery({
queryKey: ['projects', id],
queryFn: async () => {
const { data } = await api.get<Project>(`/projects/${id}`);
return data;
},
});
const toggleTaskMutation = useMutation({
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
const newStatus = status === 'completed' ? 'pending' : 'completed';
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] });
},
onError: () => {
toast.error('Failed to update task');
},
});
const deleteTaskMutation = useMutation({
mutationFn: async (taskId: number) => {
await api.delete(`/projects/${id}/tasks/${taskId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] });
toast.success('Task deleted');
setSelectedTaskId(null);
},
});
const deleteProjectMutation = useMutation({
mutationFn: async () => {
await api.delete(`/projects/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
toast.success('Project deleted');
navigate('/projects');
},
onError: () => {
toast.error('Failed to delete project');
},
});
const toggleTrackMutation = useMutation({
mutationFn: async () => {
const { data } = await api.put(`/projects/${id}`, { is_tracked: !project?.is_tracked });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
queryClient.invalidateQueries({ queryKey: ['projects', id] });
queryClient.invalidateQueries({ queryKey: ['tracked-tasks'] });
toast.success(project?.is_tracked ? 'Project untracked' : 'Project tracked');
},
onError: () => {
toast.error('Failed to update tracking');
},
});
const reorderMutation = useMutation({
mutationFn: async (items: { id: number; sort_order: number }[]) => {
await api.put(`/projects/${id}/tasks/reorder`, items);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] });
},
});
const updateTaskStatusMutation = useMutation({
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] });
},
onError: () => {
toast.error('Failed to update task status');
},
});
const allTasks = project?.tasks || [];
const topLevelTasks = useMemo(
() => allTasks.filter((t) => !t.parent_task_id),
[allTasks]
);
const sortSubtasks = useCallback(
(subtasks: ProjectTask[]): ProjectTask[] => {
if (sortMode === 'manual') return subtasks;
const sorted = [...subtasks];
if (sortMode === 'priority') {
sorted.sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 3) - (PRIORITY_ORDER[b.priority] ?? 3));
} else if (sortMode === 'due_date') {
sorted.sort((a, b) => {
if (!a.due_date && !b.due_date) return 0;
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return a.due_date.localeCompare(b.due_date);
});
}
return sorted;
},
[sortMode]
);
const sortedTasks = useMemo(() => {
const tasks = [...topLevelTasks].map((t) => ({
...t,
subtasks: sortSubtasks(t.subtasks || []),
}));
switch (sortMode) {
case 'priority':
return tasks.sort(
(a, b) => (PRIORITY_ORDER[a.priority] ?? 3) - (PRIORITY_ORDER[b.priority] ?? 3)
);
case 'due_date':
return tasks.sort((a, b) => {
if (!a.due_date && !b.due_date) return 0;
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return a.due_date.localeCompare(b.due_date);
});
case 'manual':
default:
return tasks.sort((a, b) => a.sort_order - b.sort_order);
}
}, [topLevelTasks, sortMode, sortSubtasks]);
const isDesktop = useMediaQuery(DESKTOP);
const selectedTask = useMemo(() => {
if (!selectedTaskId) return null;
// Search top-level and subtasks
for (const task of allTasks) {
if (task.id === selectedTaskId) return task;
if (task.subtasks) {
const sub = task.subtasks.find((s) => s.id === selectedTaskId);
if (sub) return sub;
}
}
return null;
}, [selectedTaskId, allTasks]);
const kanbanParentTask = useMemo(() => {
if (!kanbanParentTaskId) return null;
return topLevelTasks.find((t) => t.id === kanbanParentTaskId) || null;
}, [kanbanParentTaskId, topLevelTasks]);
const handleKanbanSelectTask = useCallback(
(taskId: number) => {
setSelectedTaskId(taskId);
// Only enter subtask view when clicking a top-level task with subtasks
// and we're not already in subtask view
if (!kanbanParentTaskId) {
const task = topLevelTasks.find((t) => t.id === taskId);
if (task && task.subtasks && task.subtasks.length > 0) {
setKanbanParentTaskId(taskId);
}
}
},
[kanbanParentTaskId, topLevelTasks]
);
const handleBackToAllTasks = useCallback(() => {
setKanbanParentTaskId(null);
setSelectedTaskId(null);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortedTasks.findIndex((t) => t.id === active.id);
const newIndex = sortedTasks.findIndex((t) => t.id === over.id);
const reordered = arrayMove(sortedTasks, oldIndex, newIndex);
const items = reordered.map((task, index) => ({
id: task.id,
sort_order: index,
}));
// Optimistic update
queryClient.setQueryData(['projects', id], (old: Project | undefined) => {
if (!old) return old;
const updated = { ...old, tasks: [...old.tasks] };
for (const item of items) {
const t = updated.tasks.find((tt) => tt.id === item.id);
if (t) t.sort_order = item.sort_order;
}
return updated;
});
reorderMutation.mutate(items);
},
[sortedTasks, id, queryClient, reorderMutation]
);
const openTaskForm = (task: ProjectTask | null, parentId: number | null) => {
setEditingTask(task);
setSubtaskParentId(parentId);
setShowTaskForm(true);
};
const closeTaskForm = () => {
setShowTaskForm(false);
setEditingTask(null);
setSubtaskParentId(null);
};
const handleDeleteTask = (taskId: number) => {
if (!window.confirm('Delete this task and all its subtasks?')) return;
deleteTaskMutation.mutate(taskId);
};
if (isLoading) {
return (
<div className="flex flex-col h-full animate-fade-in">
<div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight flex-1">Loading...</h1>
</div>
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-5">
<ListSkeleton rows={4} />
</div>
</div>
);
}
if (!project) {
return <div className="p-6 text-center text-muted-foreground">Project not found</div>;
}
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));
return (
<div className="flex flex-col h-full animate-fade-in">
{/* Header */}
<div className="border-b bg-card px-4 md:px-6 h-16 flex items-center gap-2 md:gap-4 shrink-0">
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-heading text-xl md:text-2xl font-bold tracking-tight flex-1 truncate min-w-0">
{project.name}
</h1>
<Badge className={`shrink-0 hidden sm:inline-flex ${statusColors[project.status]}`}>
{statusLabels[project.status]}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => toggleTrackMutation.mutate()}
disabled={toggleTrackMutation.isPending}
className={`shrink-0 ${project.is_tracked ? 'text-accent' : 'text-muted-foreground'}`}
title={project.is_tracked ? 'Untrack project' : 'Track project'}
>
<Pin className={`h-4 w-4 ${project.is_tracked ? 'fill-current' : ''}`} />
</Button>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setShowProjectForm(true)}>
<Pencil className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Edit</span>
</Button>
<Button
variant="ghost"
size="sm"
className="shrink-0 text-destructive hover:bg-destructive/10"
onClick={() => {
if (!window.confirm('Delete this project and all its tasks?')) return;
deleteProjectMutation.mutate();
}}
disabled={deleteProjectMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5 md:mr-2" /><span className="hidden md:inline">Delete</span>
</Button>
</div>
{/* Content area */}
<div className="flex-1 overflow-hidden flex flex-col">
{/* Summary section - scrolls with left panel on small, fixed on large */}
<div className="px-4 md:px-6 py-5 space-y-5 shrink-0 overflow-y-auto max-h-[50vh] lg:max-h-none lg:overflow-visible">
{/* 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 flex-col sm:flex-row sm:items-center gap-4 sm:gap-6">
<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>
<div className="hidden sm:block w-px h-16 bg-border" />
<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>
{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>
</div>
{/* Task list header + view controls */}
<div className="px-4 md:px-6 pb-3 flex items-center justify-between flex-wrap gap-2 shrink-0">
<h2 className="font-heading text-lg font-semibold">Tasks</h2>
<div className="flex items-center gap-2">
{/* View toggle */}
<div className="flex items-center rounded-md border border-border overflow-hidden">
<button
onClick={() => { setViewMode('list'); setKanbanParentTaskId(null); }}
className={`px-2.5 py-1.5 transition-colors ${
viewMode === 'list'
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
>
<List className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setViewMode('kanban')}
className={`px-2.5 py-1.5 transition-colors ${
viewMode === 'kanban'
? 'bg-accent/15 text-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
>
<Columns3 className="h-3.5 w-3.5" />
</button>
</div>
{/* Sort dropdown (list view only) */}
{viewMode === 'list' && (
<div className="flex items-center gap-1.5">
<ArrowUpDown className="h-3.5 w-3.5 text-muted-foreground" />
<Select
value={sortMode}
onChange={(e) => setSortMode(e.target.value as SortMode)}
className="h-8 text-xs w-auto min-w-[100px]"
>
<option value="manual">Manual</option>
<option value="priority">Priority</option>
<option value="due_date">Due Date</option>
</Select>
</div>
)}
<Button size="sm" onClick={() => openTaskForm(null, null)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Task
</Button>
</div>
</div>
{/* Main content: task list/kanban + detail panel */}
<div className="flex-1 overflow-hidden flex">
{/* Left panel: task list or kanban */}
<div className={`overflow-y-auto transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${selectedTaskId ? 'w-full lg:w-[55%]' : 'w-full'}`}>
<div className="px-4 md:px-6 pb-6">
{topLevelTasks.length === 0 ? (
<EmptyState
icon={ListChecks}
title="No tasks yet"
description="Break this project down into tasks to track your progress."
actionLabel="Add Task"
onAction={() => openTaskForm(null, null)}
/>
) : viewMode === 'kanban' ? (
<KanbanBoard
tasks={topLevelTasks}
selectedTaskId={selectedTaskId}
kanbanParentTask={kanbanParentTask}
onSelectTask={handleKanbanSelectTask}
onStatusChange={(taskId, status) =>
updateTaskStatusMutation.mutate({ taskId, status })
}
onBackToAllTasks={handleBackToAllTasks}
/>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedTasks.map((t) => t.id)}
strategy={verticalListSortingStrategy}
disabled={sortMode !== 'manual'}
>
<div className="space-y-1">
{sortedTasks.map((task) => {
const isExpanded = expandedTasks.has(task.id);
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
return (
<div key={task.id}>
<SortableTaskRow
task={task}
isSelected={selectedTaskId === task.id}
isExpanded={isExpanded}
showDragHandle={sortMode === 'manual'}
onSelect={() => setSelectedTaskId(task.id)}
onToggleExpand={() => toggleExpand(task.id)}
onToggleStatus={() =>
toggleTaskMutation.mutate({
taskId: task.id,
status: task.status,
})
}
togglePending={toggleTaskMutation.isPending}
/>
{/* Expanded subtasks */}
{isExpanded && hasSubtasks && (
<div className="ml-5 sm:ml-10 mt-0.5 space-y-0.5">
{task.subtasks.map((subtask) => (
<TaskRow
key={subtask.id}
task={subtask}
isSelected={selectedTaskId === subtask.id}
isExpanded={false}
showDragHandle={false}
onSelect={() => setSelectedTaskId(subtask.id)}
onToggleExpand={() => {}}
onToggleStatus={() =>
toggleTaskMutation.mutate({
taskId: subtask.id,
status: subtask.status,
})
}
togglePending={toggleTaskMutation.isPending}
/>
))}
</div>
)}
</div>
);
})}
</div>
</SortableContext>
</DndContext>
)}
</div>
</div>
{/* Right panel: task detail (desktop only) */}
{selectedTaskId && isDesktop && (
<div
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] border-l border-border bg-card flex w-[45%]"
>
<div className="flex-1 overflow-hidden min-w-[360px]">
<TaskDetailPanel
task={selectedTask}
projectId={parseInt(id!)}
onDelete={handleDeleteTask}
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
onClose={() => setSelectedTaskId(null)}
onSelectTask={setSelectedTaskId}
/>
</div>
</div>
)}
</div>
</div>
{/* Mobile: show detail panel as overlay when task selected on small screens */}
{selectedTaskId && selectedTask && !isDesktop && (
<MobileDetailOverlay open={true} onClose={() => setSelectedTaskId(null)}>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-medium text-muted-foreground">Task Details</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedTaskId(null)}
>
Close
</Button>
</div>
<div className="h-[calc(100%-49px)]">
<TaskDetailPanel
task={selectedTask}
projectId={parseInt(id!)}
onDelete={handleDeleteTask}
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
onClose={() => setSelectedTaskId(null)}
onSelectTask={setSelectedTaskId}
/>
</div>
</MobileDetailOverlay>
)}
{showTaskForm && (
<TaskForm
projectId={parseInt(id!)}
task={editingTask}
parentTaskId={subtaskParentId}
defaultDueDate={
subtaskParentId
? allTasks.find((t) => t.id === subtaskParentId)?.due_date
: project?.due_date
}
onClose={closeTaskForm}
/>
)}
{showProjectForm && (
<ProjectForm
project={project}
onClose={() => setShowProjectForm(false)}
/>
)}
</div>
);
}