From 59d0b0098f4a61d1e766f9e714561a336d7b744b Mon Sep 17 00:00:00 2001 From: knoxiboy Date: Wed, 15 Jul 2026 16:52:13 +0530 Subject: [PATCH 1/2] feat(kanban): implement custom workflow stages with drag-and-drop column configuration (#3183) --- messages/en.json | 1 + messages/es.json | 1 + src/app/api/kanban/[projectId]/route.ts | 83 +++ .../api/kanban/[projectId]/stages/route.ts | 133 +++++ src/app/api/kanban/[projectId]/tasks/route.ts | 136 +++++ src/app/api/kanban/route.ts | 82 +++ src/app/dashboard/kanban/[projectId]/page.tsx | 59 ++ src/app/dashboard/kanban/page.tsx | 165 ++++++ src/components/AppNavbar.tsx | 1 + src/components/kanban/KanbanBoard.tsx | 514 ++++++++++++++++++ src/components/kanban/KanbanColumn.tsx | 98 ++++ src/components/kanban/KanbanTaskCard.tsx | 87 +++ src/components/kanban/StageSettingsModal.tsx | 231 ++++++++ src/lib/leaderboard.ts | 4 +- .../20260715000000_add_kanban_boards.sql | 137 +++++ 15 files changed, 1730 insertions(+), 2 deletions(-) create mode 100644 src/app/api/kanban/[projectId]/route.ts create mode 100644 src/app/api/kanban/[projectId]/stages/route.ts create mode 100644 src/app/api/kanban/[projectId]/tasks/route.ts create mode 100644 src/app/api/kanban/route.ts create mode 100644 src/app/dashboard/kanban/[projectId]/page.tsx create mode 100644 src/app/dashboard/kanban/page.tsx create mode 100644 src/components/kanban/KanbanBoard.tsx create mode 100644 src/components/kanban/KanbanColumn.tsx create mode 100644 src/components/kanban/KanbanTaskCard.tsx create mode 100644 src/components/kanban/StageSettingsModal.tsx create mode 100644 supabase/migrations/20260715000000_add_kanban_boards.sql diff --git a/messages/en.json b/messages/en.json index 421c344f4..f42fd578d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -22,6 +22,7 @@ "resume": "Resume", "personality": "Personality", "leaderboard": "Leaderboard", + "kanban": "Kanban Board", "home": "Home", "features": "Features", "openMenu": "Open navigation menu", diff --git a/messages/es.json b/messages/es.json index d2092f0a1..6119bd0ee 100644 --- a/messages/es.json +++ b/messages/es.json @@ -22,6 +22,7 @@ "resume": "CV", "personality": "Personalidad", "leaderboard": "Clasificación", + "kanban": "Tablero Kanban", "home": "Inicio", "features": "Funciones", "openMenu": "Abrir menú de navegación", diff --git a/src/app/api/kanban/[projectId]/route.ts b/src/app/api/kanban/[projectId]/route.ts new file mode 100644 index 000000000..1960b495c --- /dev/null +++ b/src/app/api/kanban/[projectId]/route.ts @@ -0,0 +1,83 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +interface RouteParams { + params: Promise<{ + projectId: string; + }>; +} + +export async function GET(req: Request, props: RouteParams) { + const { projectId } = await props.params; + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + // Verify project belongs to user + const { data: project, error: projectError } = await supabaseAdmin + .from("projects") + .select("*") + .eq("id", projectId) + .eq("user_id", user.id) + .single(); + + if (projectError || !project) { + return Response.json({ error: "Project not found" }, { status: 404 }); + } + + // Get stages + const { data: stages, error: stagesError } = await supabaseAdmin + .from("workflow_stages") + .select("*") + .eq("project_id", projectId) + .order("position", { ascending: true }); + + if (stagesError) { + return Response.json({ error: stagesError.message }, { status: 500 }); + } + + // Get tasks + const { data: tasks, error: tasksError } = await supabaseAdmin + .from("tasks") + .select("*") + .eq("project_id", projectId) + .order("position", { ascending: true }); + + if (tasksError) { + return Response.json({ error: tasksError.message }, { status: 500 }); + } + + return Response.json({ project, stages, tasks }); +} + +export async function DELETE(req: Request, props: RouteParams) { + const { projectId } = await props.params; + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + // Delete project (cascade will delete stages and tasks) + const { error } = await supabaseAdmin + .from("projects") + .delete() + .eq("id", projectId) + .eq("user_id", user.id); + + if (error) { + return Response.json({ error: error.message }, { status: 500 }); + } + + return Response.json({ success: true }); +} diff --git a/src/app/api/kanban/[projectId]/stages/route.ts b/src/app/api/kanban/[projectId]/stages/route.ts new file mode 100644 index 000000000..63f3b1b70 --- /dev/null +++ b/src/app/api/kanban/[projectId]/stages/route.ts @@ -0,0 +1,133 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +interface RouteParams { + params: Promise<{ + projectId: string; + }>; +} + +export async function POST(req: Request, props: RouteParams) { + const { projectId } = await props.params; + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + // Verify project belongs to user + const { data: project, error: projectError } = await supabaseAdmin + .from("projects") + .select("*") + .eq("id", projectId) + .eq("user_id", user.id) + .single(); + + if (projectError || !project) { + return Response.json({ error: "Project not found" }, { status: 404 }); + } + + let body; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { name, color, position } = body; + if (!name) { + return Response.json({ error: "Stage name is required" }, { status: 400 }); + } + if (typeof position !== "number") { + return Response.json({ error: "Position must be a number" }, { status: 400 }); + } + + const { data: stage, error: stageError } = await supabaseAdmin + .from("workflow_stages") + .insert({ + project_id: projectId, + name, + color: color || "#6366f1", + position, + }) + .select("*") + .single(); + + if (stageError) { + return Response.json({ error: stageError.message }, { status: 500 }); + } + + return Response.json({ stage }, { status: 201 }); +} + +export async function PUT(req: Request, props: RouteParams) { + const { projectId } = await props.params; + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + // Verify project belongs to user + const { data: project, error: projectError } = await supabaseAdmin + .from("projects") + .select("*") + .eq("id", projectId) + .eq("user_id", user.id) + .single(); + + if (projectError || !project) { + return Response.json({ error: "Project not found" }, { status: 404 }); + } + + let body; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { stages, deleteStageId } = body; + + if (deleteStageId) { + // Delete the stage + const { error: deleteError } = await supabaseAdmin + .from("workflow_stages") + .delete() + .eq("id", deleteStageId) + .eq("project_id", projectId); + + if (deleteError) { + return Response.json({ error: deleteError.message }, { status: 500 }); + } + } + + if (stages && Array.isArray(stages)) { + // Bulk upsert/update positions and attributes of stages + const payload = stages.map((s) => ({ + id: s.id, + project_id: projectId, + name: s.name, + color: s.color || "#6366f1", + position: s.position, + })); + + const { error: upsertError } = await supabaseAdmin + .from("workflow_stages") + .upsert(payload, { onConflict: "id" }); + + if (upsertError) { + return Response.json({ error: upsertError.message }, { status: 500 }); + } + } + + return Response.json({ success: true }); +} diff --git a/src/app/api/kanban/[projectId]/tasks/route.ts b/src/app/api/kanban/[projectId]/tasks/route.ts new file mode 100644 index 000000000..94283bb59 --- /dev/null +++ b/src/app/api/kanban/[projectId]/tasks/route.ts @@ -0,0 +1,136 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +interface RouteParams { + params: Promise<{ + projectId: string; + }>; +} + +export async function POST(req: Request, props: RouteParams) { + const { projectId } = await props.params; + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + // Verify project belongs to user + const { data: project, error: projectError } = await supabaseAdmin + .from("projects") + .select("*") + .eq("id", projectId) + .eq("user_id", user.id) + .single(); + + if (projectError || !project) { + return Response.json({ error: "Project not found" }, { status: 404 }); + } + + let body; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { title, description, stageId, position } = body; + if (!title) { + return Response.json({ error: "Task title is required" }, { status: 400 }); + } + if (!stageId) { + return Response.json({ error: "Stage ID is required" }, { status: 400 }); + } + + const { data: task, error: taskError } = await supabaseAdmin + .from("tasks") + .insert({ + project_id: projectId, + stage_id: stageId, + title, + description: description || "", + position: typeof position === "number" ? position : 0, + }) + .select("*") + .single(); + + if (taskError) { + return Response.json({ error: taskError.message }, { status: 500 }); + } + + return Response.json({ task }, { status: 201 }); +} + +export async function PUT(req: Request, props: RouteParams) { + const { projectId } = await props.params; + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + // Verify project belongs to user + const { data: project, error: projectError } = await supabaseAdmin + .from("projects") + .select("*") + .eq("id", projectId) + .eq("user_id", user.id) + .single(); + + if (projectError || !project) { + return Response.json({ error: "Project not found" }, { status: 404 }); + } + + let body; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { tasks, deleteTaskId } = body; + + if (deleteTaskId) { + // Delete the task + const { error: deleteError } = await supabaseAdmin + .from("tasks") + .delete() + .eq("id", deleteTaskId) + .eq("project_id", projectId); + + if (deleteError) { + return Response.json({ error: deleteError.message }, { status: 500 }); + } + } + + if (tasks && Array.isArray(tasks)) { + // Bulk upsert/update positions and attributes of tasks + const payload = tasks.map((t) => ({ + id: t.id, + project_id: projectId, + stage_id: t.stage_id, + title: t.title, + description: t.description || "", + position: t.position, + updated_at: new Date().toISOString(), + })); + + const { error: upsertError } = await supabaseAdmin + .from("tasks") + .upsert(payload, { onConflict: "id" }); + + if (upsertError) { + return Response.json({ error: upsertError.message }, { status: 500 }); + } + } + + return Response.json({ success: true }); +} diff --git a/src/app/api/kanban/route.ts b/src/app/api/kanban/route.ts new file mode 100644 index 000000000..c1f802af1 --- /dev/null +++ b/src/app/api/kanban/route.ts @@ -0,0 +1,82 @@ +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + const { data: projects, error } = await supabaseAdmin + .from("projects") + .select("*") + .eq("user_id", user.id) + .order("created_at", { ascending: false }); + + if (error) { + return Response.json({ error: error.message }, { status: 500 }); + } + + return Response.json({ projects }); +} + +export async function POST(req: Request) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + let body; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const name = body.name?.trim(); + if (!name) { + return Response.json({ error: "Project name is required" }, { status: 400 }); + } + + const { data: project, error: projectError } = await supabaseAdmin + .from("projects") + .insert({ + user_id: user.id, + name, + }) + .select("*") + .single(); + + if (projectError || !project) { + return Response.json({ error: projectError?.message ?? "Failed to create project" }, { status: 500 }); + } + + // Seed default stages: "To Do", "In Progress", "Done" + const defaultStages = [ + { name: "To Do", position: 0, color: "#6366f1", project_id: project.id }, + { name: "In Progress", position: 1, color: "#f59e0b", project_id: project.id }, + { name: "Done", position: 2, color: "#10b981", project_id: project.id }, + ]; + + const { error: stagesError } = await supabaseAdmin + .from("workflow_stages") + .insert(defaultStages); + + if (stagesError) { + // Clean up created project if stage seeding fails + await supabaseAdmin.from("projects").delete().eq("id", project.id); + return Response.json({ error: "Failed to seed default stages" }, { status: 500 }); + } + + return Response.json({ project }, { status: 201 }); +} diff --git a/src/app/dashboard/kanban/[projectId]/page.tsx b/src/app/dashboard/kanban/[projectId]/page.tsx new file mode 100644 index 000000000..fed6decb2 --- /dev/null +++ b/src/app/dashboard/kanban/[projectId]/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { use, useEffect, useState } from "react"; +import Link from "next/link"; +import { ArrowLeft, FolderKanban } from "lucide-react"; +import KanbanBoard from "@/components/kanban/KanbanBoard"; + +interface ProjectPageProps { + params: Promise<{ + projectId: string; + }>; +} + +export default function KanbanProjectPage({ params }: ProjectPageProps) { + const { projectId } = use(params); + const [projectName, setProjectName] = useState(""); + + useEffect(() => { + fetch(`/api/kanban/${projectId}`) + .then((res) => { + if (res.ok) return res.json(); + throw new Error(); + }) + .then((data) => { + setProjectName(data.project?.name ?? ""); + }) + .catch(() => {}); + }, [projectId]); + + return ( +
+ {/* Back button and title */} +
+ + + +
+
+ +

+ {projectName || "Loading project..."} +

+
+

+ Kanban Workspace +

+
+
+ + {/* Kanban Board Container */} +
+ +
+
+ ); +} diff --git a/src/app/dashboard/kanban/page.tsx b/src/app/dashboard/kanban/page.tsx new file mode 100644 index 000000000..342203743 --- /dev/null +++ b/src/app/dashboard/kanban/page.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { FolderKanban, Plus, Trash2, ArrowRight } from "lucide-react"; + +interface Project { + id: string; + name: string; + created_at: string; +} + +export default function KanbanLandingPage() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newProjectName, setNewProjectName] = useState(""); + const [creating, setCreating] = useState(false); + + const fetchProjects = async () => { + try { + const res = await fetch("/api/kanban"); + if (!res.ok) throw new Error("Failed to load projects"); + const data = await res.json(); + setProjects(data.projects || []); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchProjects(); + }, []); + + const handleCreateProject = async (e: React.FormEvent) => { + e.preventDefault(); + const name = newProjectName.trim(); + if (!name) return; + + setCreating(true); + try { + const res = await fetch("/api/kanban", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (!res.ok) throw new Error("Failed to create project"); + setNewProjectName(""); + await fetchProjects(); + } catch (err: any) { + alert(err.message); + } finally { + setCreating(false); + } + }; + + const handleDeleteProject = async (id: string, e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (!confirm("Are you sure you want to delete this project? This will permanently delete all workflow columns and tasks inside it.")) return; + + try { + const res = await fetch(`/api/kanban/${id}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to delete project"); + await fetchProjects(); + } catch (err: any) { + alert(err.message); + } + }; + + return ( +
+ {/* Title */} +
+
+ +

Kanban Boards

+
+

+ Manage your developer tasks and project stages in custom workspaces. +

+
+ + {/* Grid of Projects */} +
+ {/* Create Project Card */} +
+

New Project

+
+ setNewProjectName(e.target.value)} + className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] focus:border-[var(--accent)] focus:outline-none" + required + /> + +
+
+ + {/* Existing Projects List */} +
+ {loading ? ( +
+
+
+ ) : error ? ( +
+ {error} +
+ ) : projects.length === 0 ? ( +
+ No projects created yet. Use the card to get started! +
+ ) : ( +
+ {projects.map((project) => ( + +
+

+ {project.name} +

+

+ Created on {new Date(project.created_at).toLocaleDateString()} +

+
+ +
+ + + Open Board + +
+ + ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/AppNavbar.tsx b/src/components/AppNavbar.tsx index 24d38a914..d6034fe3b 100644 --- a/src/components/AppNavbar.tsx +++ b/src/components/AppNavbar.tsx @@ -62,6 +62,7 @@ export default function AppNavbar() { if (isAuthenticated) { return [ { href: "/dashboard", label: t("overview") }, + { href: "/dashboard/kanban", label: t("kanban") }, { href: "/dashboard/career-intelligence", label: t("resume") }, { href: "/dashboard/personality", label: t("personality") }, { href: "/leaderboard", label: t("leaderboard") }, diff --git a/src/components/kanban/KanbanBoard.tsx b/src/components/kanban/KanbanBoard.tsx new file mode 100644 index 000000000..cc580c3a9 --- /dev/null +++ b/src/components/kanban/KanbanBoard.tsx @@ -0,0 +1,514 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + DndContext, + useSensor, + useSensors, + PointerSensor, + DragStartEvent, + DragOverEvent, + DragEndEvent, + DragOverlay, + defaultDropAnimationSideEffects, +} from "@dnd-kit/core"; +import { SortableContext, horizontalListSortingStrategy, arrayMove } from "@dnd-kit/sortable"; +import { Plus, Settings, Sparkles } from "lucide-react"; +import KanbanColumn from "./KanbanColumn"; +import StageSettingsModal from "./StageSettingsModal"; +import KanbanTaskCard from "./KanbanTaskCard"; + +interface Stage { + id: string; + name: string; + color: string; + position: number; +} + +interface Task { + id: string; + project_id: string; + stage_id: string; + title: string; + description: string; + position: number; +} + +interface KanbanBoardProps { + projectId: string; +} + +export default function KanbanBoard({ projectId }: KanbanBoardProps) { + const [stages, setStages] = useState([]); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Modals / forms state + const [showStageSettings, setShowStageSettings] = useState(false); + const [showTaskForm, setShowTaskForm] = useState(false); + const [taskFormStageId, setTaskFormStageId] = useState(null); + const [taskFormTask, setTaskFormTask] = useState(null); + const [taskTitle, setTaskTitle] = useState(""); + const [taskDesc, setTaskDesc] = useState(""); + + // Drag overlays + const [activeColumn, setActiveColumn] = useState(null); + const [activeTask, setActiveTask] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + const fetchData = async () => { + try { + const res = await fetch(`/api/kanban/${projectId}`); + if (!res.ok) throw new Error("Failed to load project details"); + const data = await res.json(); + setStages(data.stages || []); + setTasks(data.tasks || []); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, [projectId]); + + const handleSaveStages = async (updatedStages: Stage[], deleteStageId?: string) => { + try { + const res = await fetch(`/api/kanban/${projectId}/stages`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stages: updatedStages, deleteStageId }), + }); + if (!res.ok) throw new Error("Failed to save stages configuration"); + await fetchData(); + } catch (err: any) { + alert(err.message); + } + }; + + const handleSaveTask = async (e: React.FormEvent) => { + e.preventDefault(); + if (!taskTitle.trim()) return; + + try { + if (taskFormTask) { + // Edit existing task + const updatedTasks = tasks.map((t) => + t.id === taskFormTask.id ? { ...t, title: taskTitle, description: taskDesc } : t + ); + const res = await fetch(`/api/kanban/${projectId}/tasks`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tasks: updatedTasks }), + }); + if (!res.ok) throw new Error("Failed to update task"); + } else { + // Create new task + const stageId = taskFormStageId!; + const stageTasks = tasks.filter((t) => t.stage_id === stageId); + const res = await fetch(`/api/kanban/${projectId}/tasks`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: taskTitle, + description: taskDesc, + stageId, + position: stageTasks.length, + }), + }); + if (!res.ok) throw new Error("Failed to create task"); + } + setTaskTitle(""); + setTaskDesc(""); + setShowTaskForm(false); + setTaskFormTask(null); + await fetchData(); + } catch (err: any) { + alert(err.message); + } + }; + + const handleDeleteTask = async (taskId: string) => { + if (!confirm("Are you sure you want to delete this task?")) return; + try { + const res = await fetch(`/api/kanban/${projectId}/tasks`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deleteTaskId: taskId }), + }); + if (!res.ok) throw new Error("Failed to delete task"); + await fetchData(); + } catch (err: any) { + alert(err.message); + } + }; + + // Drag and Drop Handlers + const handleDragStart = (event: DragStartEvent) => { + const { active } = event; + const activeId = active.id.toString(); + + // Check if dragging a column + const column = stages.find((s) => s.id === activeId); + if (column) { + setActiveColumn(column); + return; + } + + // Otherwise dragging a task + const task = tasks.find((t) => t.id === activeId); + if (task) { + setActiveTask(task); + } + }; + + const handleDragOver = (event: DragOverEvent) => { + const { active, over } = event; + if (!over) return; + + const activeId = active.id.toString(); + const overId = over.id.toString(); + + if (activeId === overId) return; + + // Check if we are dragging a task + const isActiveTask = tasks.some((t) => t.id === activeId); + if (!isActiveTask) return; + + // Determine target column and position + let targetStageId = overId; + const isOverTask = tasks.some((t) => t.id === overId); + + if (isOverTask) { + const overTask = tasks.find((t) => t.id === overId); + targetStageId = overTask!.stage_id; + } else { + const overColumn = stages.some((s) => s.id === overId); + if (!overColumn) return; + } + + // Move task in local state visually for snappy feedback + setTasks((prevTasks) => { + const activeIdx = prevTasks.findIndex((t) => t.id === activeId); + const activeTaskItem = prevTasks[activeIdx]; + + if (activeTaskItem.stage_id !== targetStageId) { + // Change stage + const updated = [...prevTasks]; + updated[activeIdx] = { ...activeTaskItem, stage_id: targetStageId }; + return updated; + } + + return prevTasks; + }); + }; + + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + setActiveColumn(null); + setActiveTask(null); + + if (!over) return; + + const activeId = active.id.toString(); + const overId = over.id.toString(); + + // 1. Column reordering + if (activeColumn) { + if (activeId !== overId) { + const oldIndex = stages.findIndex((s) => s.id === activeId); + const newIndex = stages.findIndex((s) => s.id === overId); + + const reorderedStages = arrayMove(stages, oldIndex, newIndex).map((s, idx) => ({ + ...s, + position: idx, + })); + + setStages(reorderedStages); + await handleSaveStages(reorderedStages); + } + return; + } + + // 2. Task reordering or moving + if (activeTask) { + const activeIdx = tasks.findIndex((t) => t.id === activeId); + const activeTaskItem = tasks[activeIdx]; + + let targetStageId = overId; + const isOverTask = tasks.some((t) => t.id === overId); + + if (isOverTask) { + const overTask = tasks.find((t) => t.id === overId); + targetStageId = overTask!.stage_id; + } + + // Re-order active stage tasks + let updatedTasks = [...tasks]; + + if (activeTaskItem.stage_id !== targetStageId) { + // Move task between columns + updatedTasks[activeIdx] = { ...activeTaskItem, stage_id: targetStageId }; + } + + // Recalculate positions for all stages affected + const finalTasks = updatedTasks.map((t) => t); // Clone + + // Re-index positions within each column + stages.forEach((stage) => { + const stageTasks = finalTasks + .filter((t) => t.stage_id === stage.id) + .sort((a, b) => a.position - b.position); + + // If the task was moved or reordered, make sure it matches the drop target position + if (stage.id === targetStageId) { + const activeIndexInStage = stageTasks.findIndex((t) => t.id === activeId); + if (activeIndexInStage !== -1) { + const overIndexInStage = stageTasks.findIndex((t) => t.id === overId); + if (overIndexInStage !== -1 && activeIndexInStage !== overIndexInStage) { + const movedTasks = arrayMove(stageTasks, activeIndexInStage, overIndexInStage); + movedTasks.forEach((t, idx) => { + t.position = idx; + }); + } else { + stageTasks.forEach((t, idx) => { + t.position = idx; + }); + } + } else { + stageTasks.forEach((t, idx) => { + t.position = idx; + }); + } + } else { + stageTasks.forEach((t, idx) => { + t.position = idx; + }); + } + }); + + setTasks(finalTasks); + + // Save to database + try { + const res = await fetch(`/api/kanban/${projectId}/tasks`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tasks: finalTasks }), + }); + if (!res.ok) throw new Error("Failed to save tasks configuration"); + await fetchData(); + } catch (err: any) { + alert(err.message); + } + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ Error: {error} +
+ ); + } + + return ( +
+ {/* Board Header */} +
+
+ +

Project Kanban Board

+
+ +
+ + {/* Columns Container */} +
+ + s.id)} + strategy={horizontalListSortingStrategy} + > + {stages.map((stage) => ( + t.stage_id === stage.id) + .sort((a, b) => a.position - b.position)} + onAddTask={(stageId) => { + setTaskFormStageId(stageId); + setTaskFormTask(null); + setTaskTitle(""); + setTaskDesc(""); + setShowTaskForm(true); + }} + onEditTask={(task) => { + setTaskFormTask(task); + setTaskTitle(task.title); + setTaskDesc(task.description); + setShowTaskForm(true); + }} + onDeleteTask={handleDeleteTask} + /> + ))} + + + {/* Drag Overlay */} + + {activeColumn && ( + t.stage_id === activeColumn.id)} + onAddTask={() => {}} + onEditTask={() => {}} + onDeleteTask={() => {}} + /> + )} + {activeTask && ( + {}} onDelete={() => {}} /> + )} + + +
+ + {/* Configure Columns Settings Modal */} + {showStageSettings && ( + setShowStageSettings(false)} + onSave={handleSaveStages} + /> + )} + + {/* Task Creation / Edit Modal */} + {showTaskForm && ( +
+
+
+

+ {taskFormTask ? "Edit Task" : "Create New Task"} +

+ +
+ +
+ + setTaskTitle(e.target.value)} + className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] focus:border-[var(--accent)] focus:outline-none" + /> +
+ +
+ +