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]/activity/route.ts b/src/app/api/kanban/[projectId]/activity/route.ts new file mode 100644 index 000000000..e3d9bf9e6 --- /dev/null +++ b/src/app/api/kanban/[projectId]/activity/route.ts @@ -0,0 +1,68 @@ +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 }); + } + + // Parse URL query filters + const { searchParams } = new URL(req.url); + const actionType = searchParams.get("actionType"); + const dateFrom = searchParams.get("dateFrom"); + const dateTo = searchParams.get("dateTo"); + const limit = parseInt(searchParams.get("limit") || "20", 10); + const offset = parseInt(searchParams.get("offset") || "0", 10); + + let query = supabaseAdmin + .from("activity_log") + .select("*") + .eq("project_id", projectId) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); + + if (actionType) { + query = query.eq("action", actionType); + } + if (dateFrom) { + query = query.gte("created_at", dateFrom); + } + if (dateTo) { + query = query.lte("created_at", dateTo); + } + + const { data: activities, error: activitiesError } = await query; + + if (activitiesError) { + return Response.json({ error: activitiesError.message }, { status: 500 }); + } + + return Response.json({ activities }); +} diff --git a/src/app/api/kanban/[projectId]/route.ts b/src/app/api/kanban/[projectId]/route.ts new file mode 100644 index 000000000..52dc3cdad --- /dev/null +++ b/src/app/api/kanban/[projectId]/route.ts @@ -0,0 +1,93 @@ +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 }); + } + + // Get dependencies + const { data: dependencies, error: depsError } = await supabaseAdmin + .from("task_dependencies") + .select("*") + .eq("project_id", projectId); + + if (depsError) { + return Response.json({ error: depsError.message }, { status: 500 }); + } + + return Response.json({ project, stages, tasks, dependencies }); +} + +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..148ed6661 --- /dev/null +++ b/src/app/api/kanban/[projectId]/stages/route.ts @@ -0,0 +1,163 @@ +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 }); + } + + // Log activity + await supabaseAdmin.from("activity_log").insert({ + project_id: projectId, + user_id: user.id, + action: "stage_created", + entity_type: "stage", + entity_id: stage.id, + metadata: { name: stage.name }, + }); + + 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 }); + } + + // Log activity + await supabaseAdmin.from("activity_log").insert({ + project_id: projectId, + user_id: user.id, + action: "stage_deleted", + entity_type: "stage", + entity_id: deleteStageId, + metadata: { id: deleteStageId }, + }); + } + + 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 }); + } + + // Log activity + await supabaseAdmin.from("activity_log").insert({ + project_id: projectId, + user_id: user.id, + action: "stages_reordered", + entity_type: "stage", + entity_id: projectId, + metadata: { count: stages.length }, + }); + } + + return Response.json({ success: true }); +} diff --git a/src/app/api/kanban/[projectId]/tasks/[taskId]/dependencies/route.ts b/src/app/api/kanban/[projectId]/tasks/[taskId]/dependencies/route.ts new file mode 100644 index 000000000..e1bb61d7c --- /dev/null +++ b/src/app/api/kanban/[projectId]/tasks/[taskId]/dependencies/route.ts @@ -0,0 +1,107 @@ +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; + taskId: string; + }>; +} + +export async function POST(req: Request, props: RouteParams) { + const { projectId, taskId } = 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 { blockingTaskId } = body; + if (!blockingTaskId) { + return Response.json({ error: "blockingTaskId is required" }, { status: 400 }); + } + + const { data: dep, error: depError } = await supabaseAdmin + .from("task_dependencies") + .insert({ + project_id: projectId, + blocked_task_id: taskId, + blocking_task_id: blockingTaskId, + }) + .select("*") + .single(); + + if (depError) { + return Response.json({ error: depError.message }, { status: 500 }); + } + + return Response.json({ dependency: dep }, { status: 201 }); +} + +export async function DELETE(req: Request, props: RouteParams) { + const { projectId, taskId } = 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 }); + } + + const { searchParams } = new URL(req.url); + const blockingTaskId = searchParams.get("blockingTaskId"); + + if (!blockingTaskId) { + return Response.json({ error: "blockingTaskId is required" }, { status: 400 }); + } + + const { error: deleteError } = await supabaseAdmin + .from("task_dependencies") + .delete() + .eq("project_id", projectId) + .eq("blocked_task_id", taskId) + .eq("blocking_task_id", blockingTaskId); + + if (deleteError) { + return Response.json({ error: deleteError.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..51d8c052c --- /dev/null +++ b/src/app/api/kanban/[projectId]/tasks/route.ts @@ -0,0 +1,191 @@ +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 }); + } + + // Log activity + await supabaseAdmin.from("activity_log").insert({ + project_id: projectId, + user_id: user.id, + action: "task_created", + entity_type: "task", + entity_id: task.id, + metadata: { title: task.title, stage_id: task.stage_id }, + }); + + 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, movedTask, editedTask } = body; + + if (deleteTaskId) { + // Get task details before deleting for a better log + const { data: taskToDelete } = await supabaseAdmin + .from("tasks") + .select("title") + .eq("id", deleteTaskId) + .single(); + + // 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 }); + } + + // Log activity + await supabaseAdmin.from("activity_log").insert({ + project_id: projectId, + user_id: user.id, + action: "task_deleted", + entity_type: "task", + entity_id: deleteTaskId, + metadata: { title: taskToDelete?.title || "Deleted Task" }, + }); + } + + 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 }); + } + + // Log activity if moved or edited specifically + if (movedTask) { + // Find destination stage name + const { data: stage } = await supabaseAdmin + .from("workflow_stages") + .select("name") + .eq("id", movedTask.toStageId) + .single(); + + await supabaseAdmin.from("activity_log").insert({ + project_id: projectId, + user_id: user.id, + action: "task_moved", + entity_type: "task", + entity_id: movedTask.id, + metadata: { title: movedTask.title, stage_name: stage?.name || "new column" }, + }); + } else if (editedTask) { + await supabaseAdmin.from("activity_log").insert({ + project_id: projectId, + user_id: user.id, + action: "task_updated", + entity_type: "task", + entity_id: editedTask.id, + metadata: { title: editedTask.title }, + }); + } + } + + 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/ActivityFeed.tsx b/src/components/kanban/ActivityFeed.tsx new file mode 100644 index 000000000..ab2d5cad6 --- /dev/null +++ b/src/components/kanban/ActivityFeed.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Clock, Filter, Calendar, RefreshCw } from "lucide-react"; + +interface Activity { + id: string; + project_id: string; + user_id: string; + action: string; + entity_type: string; + entity_id: string; + metadata: { + title?: string; + name?: string; + stage_name?: string; + count?: number; + id?: string; + }; + created_at: string; +} + +interface ActivityFeedProps { + projectId: string; + refreshTrigger: number; +} + +const ACTION_LABELS: Record = { + task_created: "Task Created", + task_moved: "Task Moved", + task_updated: "Task Updated", + task_deleted: "Task Deleted", + stage_created: "Column Created", + stage_deleted: "Column Deleted", + stages_reordered: "Columns Reordered", +}; + +export default function ActivityFeed({ projectId, refreshTrigger }: ActivityFeedProps) { + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [offset, setOffset] = useState(0); + + // Filters + const [actionType, setActionType] = useState(""); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + + const limit = 15; + + const loadActivities = useCallback(async (isInitial = true) => { + if (isInitial) { + setLoading(true); + setOffset(0); + } else { + setLoadingMore(true); + } + + try { + const currentOffset = isInitial ? 0 : offset; + let url = `/api/kanban/${projectId}/activity?limit=${limit}&offset=${currentOffset}`; + if (actionType) url += `&actionType=${actionType}`; + if (dateFrom) url += `&dateFrom=${new Date(dateFrom).toISOString()}`; + if (dateTo) url += `&dateTo=${new Date(dateTo).toISOString()}`; + + const res = await fetch(url); + if (!res.ok) throw new Error("Failed to load activity logs"); + const data = await res.json(); + const fetched: Activity[] = data.activities || []; + + if (isInitial) { + setActivities(fetched); + } else { + setActivities((prev) => [...prev, ...fetched]); + } + + setHasMore(fetched.length === limit); + setOffset(currentOffset + fetched.length); + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, [projectId, actionType, dateFrom, dateTo, offset]); + + useEffect(() => { + loadActivities(true); + }, [projectId, actionType, dateFrom, dateTo, refreshTrigger]); + + const getActivityMessage = (activity: Activity) => { + const meta = activity.metadata; + switch (activity.action) { + case "task_created": + return ( + + created task {meta.title} + + ); + case "task_moved": + return ( + + moved task {meta.title} to {meta.stage_name} + + ); + case "task_updated": + return ( + + updated task {meta.title} + + ); + case "task_deleted": + return ( + + deleted task {meta.title} + + ); + case "stage_created": + return ( + + created column {meta.name} + + ); + case "stage_deleted": + return ( + + deleted a column + + ); + case "stages_reordered": + return ( + + reordered board columns + + ); + default: + return performed an action; + } + }; + + const getActionIconColor = (action: string) => { + switch (action) { + case "task_created": + return "bg-emerald-500/10 text-emerald-500 border-emerald-500/20"; + case "task_deleted": + case "stage_deleted": + return "bg-red-500/10 text-red-500 border-red-500/20"; + case "task_moved": + case "stages_reordered": + return "bg-indigo-500/10 text-indigo-500 border-indigo-500/20"; + default: + return "bg-amber-500/10 text-amber-500 border-amber-500/20"; + } + }; + + return ( +
+ {/* Filters header */} +
+
+

+ Activity History +

+ +
+ +
+ {/* Action filter */} +
+ + +
+ + {/* Date from */} + setDateFrom(e.target.value)} + className="w-full text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] px-2 py-1.5 text-[var(--foreground)] focus:border-[var(--accent)] focus:outline-none" + placeholder="From" + /> + + {/* Date to */} + setDateTo(e.target.value)} + className="w-full text-xs rounded-lg border border-[var(--border)] bg-[var(--background)] px-2 py-1.5 text-[var(--foreground)] focus:border-[var(--accent)] focus:outline-none" + placeholder="To" + /> +
+
+ + {/* Timeline items */} +
+ {loading ? ( +
+
+
+ ) : error ? ( +
+ Error: {error} +
+ ) : activities.length === 0 ? ( +
+ No activity matches the filters. +
+ ) : ( +
+ {activities.map((activity) => ( +
+ {/* Timeline Dot Icon */} + + {activity.action.startsWith("task") ? "T" : "C"} + + + {/* Content */} +
+

+ {getActivityMessage(activity)} +

+ + + {new Date(activity.created_at).toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + +
+
+ ))} +
+ )} +
+ + {/* Pagination Footer */} + {hasMore && !loading && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/kanban/DependencyModal.tsx b/src/components/kanban/DependencyModal.tsx new file mode 100644 index 000000000..93393964a --- /dev/null +++ b/src/components/kanban/DependencyModal.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useState } from "react"; +import { X, Plus, Trash2 } from "lucide-react"; + +interface Task { + id: string; + project_id: string; + stage_id: string; + title: string; + description: string; + position: number; +} + +interface Dependency { + id: string; + project_id: string; + blocked_task_id: string; + blocking_task_id: string; +} + +interface DependencyModalProps { + task: Task; + tasks: Task[]; + dependencies: Dependency[]; + projectId: string; + onClose: () => void; + onRefresh: () => Promise; +} + +export default function DependencyModal({ + task, + tasks, + dependencies, + projectId, + onClose, + onRefresh, +}: DependencyModalProps) { + const [selectedTaskId, setSelectedTaskId] = useState(""); + const [submitting, setSubmitting] = useState(false); + + // Blockers: tasks that are blocking this task (blocking_task_id) + const blockers = dependencies + .filter((d) => d.blocked_task_id === task.id) + .map((d) => tasks.find((t) => t.id === d.blocking_task_id)) + .filter(Boolean) as Task[]; + + // Blocked: tasks that this task is blocking (blocked_task_id) + const blockedTasks = dependencies + .filter((d) => d.blocking_task_id === task.id) + .map((d) => tasks.find((t) => t.id === d.blocked_task_id)) + .filter(Boolean) as Task[]; + + // Eligible tasks to add as blocker: other tasks in the project not already blocking this task, and not itself + const blockerIds = blockers.map((b) => b.id); + const eligibleTasks = tasks.filter( + (t) => t.id !== task.id && !blockerIds.includes(t.id) && t.id !== task.id + ); + + const handleAddDependency = async () => { + if (!selectedTaskId) return; + setSubmitting(true); + try { + const res = await fetch(`/api/kanban/${projectId}/tasks/${task.id}/dependencies`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ blockingTaskId: selectedTaskId }), + }); + if (!res.ok) throw new Error("Failed to add dependency"); + setSelectedTaskId(""); + await onRefresh(); + } catch (err: any) { + alert(err.message); + } finally { + setSubmitting(false); + } + }; + + const handleRemoveDependency = async (blockingId: string) => { + try { + const res = await fetch( + `/api/kanban/${projectId}/tasks/${task.id}/dependencies?blockingTaskId=${blockingId}`, + { method: "DELETE" } + ); + if (!res.ok) throw new Error("Failed to remove dependency"); + await onRefresh(); + } catch (err: any) { + alert(err.message); + } + }; + + return ( +
+
+
+
+

+ Manage Dependencies +

+

+ For: {task.title} +

+
+ +
+ + {/* Add Blocker Form */} +
+ +
+ + +
+
+ + {/* Blockers list */} +
+

+ Blocked By (Blockers) +

+ {blockers.length === 0 ? ( +

This task is not blocked.

+ ) : ( +
+ {blockers.map((b) => ( +
+ + {b.title} + + +
+ ))} +
+ )} +
+ + {/* Blocking list */} +
+

+ Blocking Tasks +

+ {blockedTasks.length === 0 ? ( +

This task is not blocking other tasks.

+ ) : ( +
+ {blockedTasks.map((bt) => ( +
+ {bt.title} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/kanban/KanbanBoard.tsx b/src/components/kanban/KanbanBoard.tsx new file mode 100644 index 000000000..c0b85ad4a --- /dev/null +++ b/src/components/kanban/KanbanBoard.tsx @@ -0,0 +1,600 @@ +"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, Clock } from "lucide-react"; +import KanbanColumn from "./KanbanColumn"; +import StageSettingsModal from "./StageSettingsModal"; +import KanbanTaskCard from "./KanbanTaskCard"; +import ActivityFeed from "./ActivityFeed"; +import DependencyModal from "./DependencyModal"; + +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 Dependency { + id: string; + project_id: string; + blocked_task_id: string; + blocking_task_id: string; +} + +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); + + // Activity feed state + const [showActivityFeed, setShowActivityFeed] = useState(false); + const [refreshActivityTrigger, setRefreshActivityTrigger] = useState(0); + + // Task dependencies state + const [dependencies, setDependencies] = useState([]); + const [dependencyModalTask, setDependencyModalTask] = 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 || []); + setDependencies(data.dependencies || []); + setRefreshActivityTrigger((prev) => prev + 1); + } 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, + editedTask: { id: taskFormTask.id, title: taskTitle }, + }), + }); + 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; + } + + // Check if moving to the final stage + const sortedStages = [...stages].sort((a, b) => a.position - b.position); + const finalStage = sortedStages[sortedStages.length - 1]; + + if (targetStageId === finalStage?.id) { + const blockers = dependencies + .filter((d) => d.blocked_task_id === activeId) + .map((d) => tasks.find((t) => t.id === d.blocking_task_id)) + .filter(Boolean); + + const activeBlockers = blockers.filter((b) => b!.stage_id !== finalStage.id); + + if (activeBlockers.length > 0) { + alert( + `Cannot move task to ${finalStage.name}. It is blocked by unfinished tasks:\n` + + activeBlockers.map((b) => `- ${b!.title}`).join("\n") + ); + return; + } + } + + // 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, + movedTask: { id: activeId, title: activeTaskItem.title, toStageId: targetStageId }, + }), + }); + 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)} + dependencies={dependencies} + onAddTask={(stageId) => { + setTaskFormStageId(stageId); + setTaskFormTask(null); + setTaskTitle(""); + setTaskDesc(""); + setShowTaskForm(true); + }} + onEditTask={(task) => { + setTaskFormTask(task); + setTaskTitle(task.title); + setTaskDesc(task.description); + setShowTaskForm(true); + }} + onDeleteTask={handleDeleteTask} + onManageDependencies={(task) => setDependencyModalTask(task)} + /> + ))} + + + {/* Drag Overlay */} + + {activeColumn && ( + t.stage_id === activeColumn.id)} + dependencies={[]} + onAddTask={() => {}} + onEditTask={() => {}} + onDeleteTask={() => {}} + onManageDependencies={() => {}} + /> + )} + {activeTask && ( + {}} + onDelete={() => {}} + blockersCount={0} + blockingCount={0} + onManageDependencies={() => {}} + /> + )} + + +
+ + {showActivityFeed && ( +
+ +
+ )} +
+ + {/* Configure Columns Settings Modal */} + {showStageSettings && ( + setShowStageSettings(false)} + onSave={handleSaveStages} + /> + )} + + {/* Dependency Modal */} + {dependencyModalTask && ( + setDependencyModalTask(null)} + onRefresh={fetchData} + /> + )} + + {/* 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" + /> +
+ +
+ +