'use client'; import { useEffect, useState, useCallback } from 'react'; import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Calendar, Clock, GripVertical, Link2, Loader2, Plus } from 'lucide-react'; import { toast } from 'sonner'; import { TaskDetailPanel } from './task-detail-panel'; import { useRealtimeContext } from '@/components/realtime-provider'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; interface Task { id: string; title: string; description?: string | null; status: 'todo' | 'in_progress' | 'done' | 'cancelled'; priority: 'low' | 'medium' | 'high' | 'urgent'; domainId: string; dueDate?: string | null; projectId?: string | null; sectionId?: string | null; parentId?: string | null; order: number; tags: { id: string; name: string; color: string | null }[]; completedAt?: string | null; estimatedMinutes?: number | null; } interface Domain { id: string; name: string; color: string | null; } const columns = [ { id: 'todo', title: 'To Do', color: 'bg-slate-500' }, { id: 'in_progress', title: 'In Progress', color: 'bg-blue-500' }, { id: 'done', title: 'Done', color: 'bg-green-500' }, { id: 'cancelled', title: 'Cancelled', color: 'bg-red-500' }, ]; const priorityColors: Record = { urgent: 'destructive', high: 'default', medium: 'secondary', low: 'secondary', }; function TaskCard({ task, domainName, domainColor, depCount, onClick, }: { task: Task; domainName: string; domainColor: string | null; depCount: number; onClick: () => void; }) { return ( { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } }} aria-label={task.title}>
{task.title} {depCount > 0 && (

{depCount} {depCount === 1 ? 'dependency' : 'dependencies'}

)}
{task.priority} {domainColor && (
); } function DroppableColumn({ id, title, color, tasks, domainMap, depCounts, onTaskClick, inlineInput, onInlineInputChange, onInlineSubmit, inlineLoading, }: { id: string; title: string; color: string; tasks: Task[]; domainMap: Map; depCounts: Map; onTaskClick: (task: Task) => void; inlineInput: string; onInlineInputChange: (value: string) => void; onInlineSubmit: () => void; inlineLoading: boolean; }) { return (
{tasks.length === 0 ? (

No tasks

) : ( tasks.map((task) => ( onTaskClick(task)} /> )) )}
{/* Inline task creation input */}
onInlineInputChange(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !inlineLoading) { onInlineSubmit(); } }} className="h-8 text-sm" disabled={inlineLoading} />
); } export function TasksKanbanView({ domainId, onRefresh, }: { domainId: string; onRefresh?: () => void; }) { const [tasks, setTasks] = useState([]); const [domainMap, setDomainMap] = useState>(new Map()); const [loading, setLoading] = useState(true); const [selectedTask, setSelectedTask] = useState(null); const [inlineInputs, setInlineInputs] = useState>({}); const [inlineLoading, setInlineLoading] = useState>({}); const [depCounts, setDepCounts] = useState>(new Map()); const { subscribe } = useRealtimeContext(); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) ); const fetchTasks = useCallback(async () => { if (!domainId) return; try { const response = await fetch(`/api/domains/${domainId}/tasks?sort=order&limit=200`); if (!response.ok) throw new Error('Unable to load tasks'); const data = await response.json(); setTasks(data.items || []); } catch (error) { console.error('Failed to fetch tasks:', error); toast.error('Unable to load tasks'); } finally { setLoading(false); } }, [domainId]); const fetchDomains = useCallback(async () => { try { const response = await fetch('/api/domains?sort=sort_order'); if (!response.ok) return; const data = await response.json(); const map = new Map(); for (const d of data.items || []) { map.set(d.id, { name: d.name, color: d.color || null }); } setDomainMap(map); } catch { // Non-critical } }, []); useEffect(() => { if (!domainId) return; setLoading(true); fetchTasks(); fetchDomains(); }, [domainId, fetchTasks, fetchDomains]); // Subscribe to realtime updates useEffect(() => { if (!domainId) return; const unsubscribe = subscribe(['task'], (event: any) => { if (event.type === 'task') { fetchTasks(); onRefresh?.(); } }); return unsubscribe; }, [domainId, subscribe, fetchTasks, onRefresh]); // Fetch dependency counts for visible tasks useEffect(() => { if (tasks.length === 0 || !domainId) return; const controller = new AbortController(); let cancelled = false; (async () => { const counts = new Map(); // Fetch task details in parallel to get dependency counts const results = await Promise.allSettled( tasks.map((t) => fetch(`/api/domains/${domainId}/tasks/${t.id}`, { signal: controller.signal }) .then((r) => (r.ok ? r.json() : null)) ) ); if (cancelled) return; for (let i = 0; i < results.length; i++) { const r = results[i]; if (r.status === 'fulfilled' && r.value?.dependencies?.length > 0) { counts.set(tasks[i].id, r.value.dependencies.length); } } setDepCounts(counts); })(); return () => { cancelled = true; controller.abort(); }; }, [tasks, domainId]); async function handleDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over) return; const taskId = active.id as string; const newStatus = over.id as Task['status']; const task = tasks.find((t) => t.id === taskId); if (!task || task.status === newStatus) return; // Optimistic update setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)) ); try { const response = await fetch(`/api/domains/${domainId}/tasks/${taskId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }), }); if (!response.ok) throw new Error('Unable to move task'); toast.success( `Moved "${task.title}" to ${columns.find((c) => c.id === newStatus)?.title}` ); } catch (error) { console.error('Failed to update task status:', error); toast.error(`Unable to move "${task.title}"`); fetchTasks(); // Revert } } async function handleInlineCreate(status: string) { const title = inlineInputs[status]?.trim(); if (!title) return; setInlineLoading((prev) => ({ ...prev, [status]: true })); try { const response = await fetch(`/api/domains/${domainId}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, status }), }); if (!response.ok) throw new Error('Unable to create task'); const newTask = await response.json(); // Prepend the new task to the list setTasks((prev) => [{ ...newTask, tags: [] }, ...prev]); setInlineInputs((prev) => ({ ...prev, [status]: '' })); toast.success(`Created "${title}"`); } catch (error) { console.error('Failed to create task:', error); toast.error('Unable to create task'); } finally { setInlineLoading((prev) => ({ ...prev, [status]: false })); } } if (loading) { return

Loading tasks...

; } return ( <>
{columns.map((column) => ( t.status === column.id)} domainMap={domainMap} depCounts={depCounts} onTaskClick={setSelectedTask} inlineInput={inlineInputs[column.id] || ''} onInlineInputChange={(value) => setInlineInputs((prev) => ({ ...prev, [column.id]: value })) } onInlineSubmit={() => handleInlineCreate(column.id)} inlineLoading={inlineLoading[column.id] || false} /> ))}
{selectedTask && ( !open && setSelectedTask(null)} onUpdate={fetchTasks} /> )} ); }