diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index 1b001ac..450563c 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -63,9 +63,8 @@ const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"]; const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"]; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -// Optimized field selection - only fetch fields needed for board display -// Full task details (description, comments, attachments) fetched lazily -const TASK_FIELDS_LIGHT = [ +// Optimized field selection - fetch all fields needed for board and detail display +const TASK_FIELDS = [ "id", "title", "type", @@ -80,6 +79,9 @@ const TASK_FIELDS_LIGHT = [ "assignee_id", "due_date", "tags", + "comments", + "attachments", + "description", ]; class HttpError extends Error { @@ -287,7 +289,7 @@ export async function GET() { ] = await Promise.all([ supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }), supabase.from("sprints").select("id, name, goal, start_date, end_date, status, project_id, created_at").order("start_date", { ascending: true }), - supabase.from("tasks").select(TASK_FIELDS_LIGHT.join(", ")).order("updated_at", { ascending: false }), + supabase.from("tasks").select(TASK_FIELDS.join(", ")).order("updated_at", { ascending: false }), supabase.from("users").select("id, name, email, avatar_url"), ]); diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx new file mode 100644 index 0000000..78f9ab0 --- /dev/null +++ b/src/app/projects/[id]/page.tsx @@ -0,0 +1,760 @@ +"use client" + +import { useEffect, useState, useMemo } from "react" +import { useRouter, useParams } from "next/navigation" +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, + closestCorners, +} from "@dnd-kit/core" +import { + SortableContext, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { + ArrowLeft, + FolderKanban, + Plus, + Edit3, + Check, + X, + GripVertical, + LayoutGrid, + ListTodo, + Trash2, + MessageSquare, + Inbox +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { useTaskStore, Task, Project } from "@/stores/useTaskStore" +import { toast } from "sonner" +import { parseSprintStart } from "@/lib/utils" + +const PRESET_COLORS = [ + "#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6", + "#ec4899", "#06b6d4", "#f97316", "#84cc16", "#6366f1", +] + +const priorityColors: Record = { + low: "bg-slate-600", + medium: "bg-blue-600", + high: "bg-orange-600", + urgent: "bg-red-600", +} + +const statusLabels: Record = { + open: "Open", + todo: "To Do", + blocked: "Blocked", + "in-progress": "In Progress", + review: "Review", + validate: "Validate", + archived: "Archived", + canceled: "Canceled", + done: "Done", +} + +// Sortable Task Card Component +function SortableTaskCard({ + task, + onClick, +}: { + task: Task + onClick: () => void +}) { + 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 ( + + +
+
+ +
+
+
+

+ {task.title} +

+
+
+ + {task.priority} + + + {task.type} + + + {statusLabels[task.status]} + + {task.comments && task.comments.length > 0 && ( + + + {task.comments.length} + + )} +
+ {task.assigneeName && ( +

+ Assigned to: {task.assigneeName} +

+ )} +
+
+
+
+ ) +} + +// Drag Overlay Task Card +function DragOverlayTaskCard({ task }: { task: Task }) { + return ( + + +
+ +
+

+ {task.title} +

+
+ + {task.priority} + + + {task.type} + +
+
+
+
+
+ ) +} + +// Unassigned Tasks Panel Component +function UnassignedPanel({ + tasks, + onTaskClick, +}: { + tasks: Task[] + onTaskClick: (taskId: string) => void +}) { + const { setNodeRef, isOver } = useSortable({ + id: "unassigned-drop-zone", + data: { type: "unassigned" }, + }) + + return ( +
+
+
+ +

Unassigned Tasks

+
+ + {tasks.length} + +
+ + t.id)} strategy={verticalListSortingStrategy}> +
+ {tasks.length === 0 ? ( +
+

Drop tasks here to unassign

+

from this project

+
+ ) : ( + tasks.map((task) => ( + onTaskClick(task.id)} + /> + )) + )} +
+
+
+ ) +} + +export default function ProjectDetailPage() { + const params = useParams<{ id: string }>() + const router = useRouter() + const projectId = params.id + + const { + projects, + tasks, + sprints, + currentUser, + updateTask, + updateProject, + deleteProject, + syncFromServer, + addTask, + } = useTaskStore() + + const [authReady, setAuthReady] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [project, setProject] = useState(null) + + // Edit state + const [isEditing, setIsEditing] = useState(false) + const [editName, setEditName] = useState("") + const [editDescription, setEditDescription] = useState("") + const [editColor, setEditColor] = useState("") + + // Drag and drop + const [activeId, setActiveId] = useState(null) + + // New task dialog + const [newTaskOpen, setNewTaskOpen] = useState(false) + const [newTask, setNewTask] = useState>({ + title: "", + description: "", + type: "task", + priority: "medium", + status: "open", + }) + + // Sensors for drag detection + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }) + ) + + // Check auth + useEffect(() => { + let isMounted = true + const loadSession = async () => { + try { + const res = await fetch("/api/auth/session", { cache: "no-store" }) + if (!res.ok) { + if (isMounted) router.replace("/login") + return + } + if (isMounted) setAuthReady(true) + } catch { + if (isMounted) router.replace("/login") + } + } + loadSession() + return () => { isMounted = false } + }, [router]) + + // Sync and find project + useEffect(() => { + if (!authReady) return + + const load = async () => { + await syncFromServer() + const found = projects.find((p) => p.id === projectId) + if (found) { + setProject(found) + setEditName(found.name) + setEditDescription(found.description || "") + setEditColor(found.color) + } + setIsLoading(false) + } + load() + }, [authReady, projectId, projects, syncFromServer]) + + // Get project tasks + const projectTasks = useMemo(() => { + return tasks + .filter((t) => t.projectId === projectId) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) + }, [tasks, projectId]) + + // Get unassigned tasks (for this user's view - tasks without project) + const unassignedTasks = useMemo(() => { + return tasks + .filter((t) => !t.projectId && t.createdById === currentUser.id) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) + }, [tasks, currentUser.id]) + + // Get project sprints + const projectSprints = useMemo(() => { + return sprints + .filter((s) => s.projectId === projectId) + .sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()) + }, [sprints, projectId]) + + // Stats + const stats = useMemo(() => { + const total = projectTasks.length + const completed = projectTasks.filter((t) => t.status === "done" || t.status === "archived").length + const inProgress = projectTasks.filter((t) => t.status === "in-progress" || t.status === "review").length + const todo = projectTasks.filter((t) => t.status === "open" || t.status === "todo").length + return { total, completed, inProgress, todo } + }, [projectTasks]) + + const handleSaveEdit = async () => { + if (!project || !editName.trim()) { + toast.error("Project name is required") + return + } + + try { + await updateProject(project.id, { + name: editName.trim(), + description: editDescription.trim() || undefined, + color: editColor, + }) + toast.success("Project updated successfully") + setIsEditing(false) + } catch (error) { + toast.error("Failed to update project") + console.error(error) + } + } + + const handleDeleteProject = async () => { + if (!project) return + if (!window.confirm("Are you sure you want to delete this project? This cannot be undone.")) return + + try { + await deleteProject(project.id) + toast.success("Project deleted successfully") + router.push("/projects") + } catch (error) { + toast.error("Failed to delete project") + console.error(error) + } + } + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string) + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + setActiveId(null) + + if (!over) return + + const taskId = active.id as string + const overId = over.id as string + + // If dropped on unassigned zone, remove project association + if (overId === "unassigned-drop-zone") { + const task = tasks.find((t) => t.id === taskId) + if (task && task.projectId === projectId) { + updateTask(taskId, { projectId: undefined, sprintId: undefined }) + toast.success("Task unassigned from project") + } + return + } + + // If dropped on project zone, assign to this project + if (overId === "project-drop-zone") { + const task = tasks.find((t) => t.id === taskId) + if (task && task.projectId !== projectId) { + updateTask(taskId, { projectId }) + toast.success("Task assigned to project") + } + return + } + + // If dropped on another task, we could reorder (future enhancement) + const overTask = tasks.find((t) => t.id === overId) + if (overTask) { + // Check if we need to move between projects + const activeTask = tasks.find((t) => t.id === taskId) + if (activeTask && overTask.projectId !== activeTask.projectId) { + if (overTask.projectId === projectId) { + updateTask(taskId, { projectId }) + toast.success("Task assigned to project") + } else if (!overTask.projectId) { + updateTask(taskId, { projectId: undefined, sprintId: undefined }) + toast.success("Task unassigned from project") + } + } + } + } + + const handleAddTask = async () => { + if (!newTask.title?.trim() || !project) return + + try { + await addTask({ + title: newTask.title.trim(), + description: newTask.description?.trim() || undefined, + type: (newTask.type || "task") as Task["type"], + priority: (newTask.priority || "medium") as Task["priority"], + status: (newTask.status || "open") as Task["status"], + projectId: project.id, + tags: [], + }) + toast.success("Task created successfully") + setNewTaskOpen(false) + setNewTask({ + title: "", + description: "", + type: "task", + priority: "medium", + status: "open", + }) + } catch (error) { + toast.error("Failed to create task") + console.error(error) + } + } + + const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null + + if (!authReady) { + return ( +
+

Checking session...

+
+ ) + } + + if (isLoading) { + return ( +
+
+ + + + + Loading project... +
+
+ ) + } + + if (!project) { + return ( +
+
+ +

Project Not Found

+

The project you're looking for doesn't exist.

+ +
+
+ ) + } + + return ( + +
+ {/* Header */} +
+
+
+
+ + + {isEditing ? ( +
+ setEditName(e.target.value)} + className="bg-slate-800 border-slate-700 text-white w-64" + placeholder="Project name" + autoFocus + /> +
+ {PRESET_COLORS.map((color) => ( +
+ + +
+ ) : ( +
+
+
+

+ {project.name} +

+ {project.description && ( +

{project.description}

+ )} +
+ +
+ )} +
+ +
+ + +
+
+
+
+ +
+ {/* Stats */} +
+
+

{stats.total}

+

Total Tasks

+
+
+

{stats.completed}

+

Completed

+
+
+

{stats.inProgress}

+

In Progress

+
+
+

{stats.todo}

+

To Do

+
+
+ + {/* Progress bar */} + {stats.total > 0 && ( +
+
+ Project Progress + + {Math.round((stats.completed / stats.total) * 100)}% Complete + +
+
+
+
+
+ )} + +
+ {/* Project Tasks */} +
+
+
+
+ +

Project Tasks

+
+ + {projectTasks.length} + +
+ + {projectSprints.length > 0 && ( +
+ Sprints: + {projectSprints.map((sprint) => ( + + {sprint.name} + + ))} +
+ )} + + t.id)} strategy={verticalListSortingStrategy}> +
+ {projectTasks.length === 0 ? ( +
+ +

No tasks in this project yet

+

Drag tasks here or click "Add Task"

+
+ ) : ( + projectTasks.map((task) => ( + router.push(`/tasks/${task.id}`)} + /> + )) + )} +
+
+
+
+ + {/* Unassigned Tasks Panel */} +
+ router.push(`/tasks/${taskId}`)} + /> +
+
+
+ + {/* New Task Dialog */} + + + + Create New Task + +
+
+ + setNewTask({ ...newTask, title: e.target.value })} + placeholder="Task title" + className="mt-1 bg-slate-800 border-slate-700 text-white" + /> +
+
+ +