- All page toolbars now flex-wrap on mobile with min-h instead of fixed h-16 - Segmented button filters (priority, status, view) hidden on mobile, replaced with compact Select dropdowns - Search inputs hidden on mobile where CategoryFilterBar already has search - CategoryFilterBar wraps to full-width row on mobile (order-last) - Action buttons show icon-only on mobile, full text on md+ - Calendar title hidden on xs screens for space - Desktop layout completely unchanged (md:flex-nowrap restores original) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
702 lines
26 KiB
TypeScript
702 lines
26 KiB
TypeScript
import { useState, useMemo, useCallback } from 'react';
|
|
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';
|
|
|
|
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 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-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-4 shrink-0">
|
|
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Button>
|
|
<h1 className="font-heading text-2xl font-bold tracking-tight flex-1 truncate">
|
|
{project.name}
|
|
</h1>
|
|
<Badge className={statusColors[project.status]}>
|
|
{statusLabels[project.status]}
|
|
</Badge>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => toggleTrackMutation.mutate()}
|
|
disabled={toggleTrackMutation.isPending}
|
|
className={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" 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="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 items-center 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="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-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 (hidden on small screens) */}
|
|
<div
|
|
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] border-l border-border bg-card ${
|
|
selectedTaskId ? 'hidden lg:flex lg:w-[45%]' : 'w-0 opacity-0 border-l-0'
|
|
}`}
|
|
>
|
|
<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 && (
|
|
<div className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
|
|
<div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
);
|
|
}
|