diff --git a/frontend/src/app/_components/Shell.tsx b/frontend/src/app/_components/Shell.tsx index f0ad53f2..577c3a9e 100644 --- a/frontend/src/app/_components/Shell.tsx +++ b/frontend/src/app/_components/Shell.tsx @@ -8,6 +8,7 @@ import styles from "./Shell.module.css"; const NAV = [ { href: "/", label: "Mission Control" }, { href: "/projects", label: "Projects" }, + { href: "/kanban", label: "Kanban" }, { href: "/departments", label: "Departments" }, { href: "/people", label: "People" }, { href: "/hr", label: "HR" }, diff --git a/frontend/src/app/kanban/page.tsx b/frontend/src/app/kanban/page.tsx new file mode 100644 index 00000000..ab91ec91 --- /dev/null +++ b/frontend/src/app/kanban/page.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select } from "@/components/ui/select"; + +import { useListProjectsProjectsGet } from "@/api/generated/projects/projects"; +import { useListEmployeesEmployeesGet } from "@/api/generated/org/org"; +import { useListTasksTasksGet, useUpdateTaskTasksTaskIdPatch } from "@/api/generated/work/work"; + +const STATUSES = ["backlog", "ready", "in_progress", "review", "blocked", "done"] as const; + +export default function KanbanPage() { + const projects = useListProjectsProjectsGet(); + const projectList = projects.data ?? []; + + const employees = useListEmployeesEmployeesGet(); + const employeeList = useMemo(() => employees.data ?? [], [employees.data]); + + const [projectId, setProjectId] = useState(""); + const [assigneeId, setAssigneeId] = useState(""); + + const tasks = useListTasksTasksGet({ + ...(projectId ? { project_id: Number(projectId) } : {}), + }); + const taskList = useMemo(() => tasks.data ?? [], [tasks.data]); + + const updateTask = useUpdateTaskTasksTaskIdPatch({ + mutation: { + onSuccess: () => tasks.refetch(), + }, + }); + + const employeeNameById = useMemo(() => { + const m = new Map(); + for (const e of employeeList) { + if (e.id != null) m.set(e.id, e.name); + } + return m; + }, [employeeList]); + + const filtered = useMemo(() => { + return taskList.filter((t) => { + if (assigneeId && String(t.assignee_employee_id ?? "") !== assigneeId) return false; + return true; + }); + }, [taskList, assigneeId]); + + const tasksByStatus = useMemo(() => { + const map = new Map<(typeof STATUSES)[number], typeof filtered>(); + for (const s of STATUSES) map.set(s, []); + for (const t of filtered) { + const s = (t.status ?? "backlog") as (typeof STATUSES)[number]; + (map.get(s) ?? map.get("backlog"))?.push(t); + } + // stable sort inside each column + for (const s of STATUSES) { + const arr = map.get(s) ?? []; + arr.sort((a, b) => String(a.id ?? 0).localeCompare(String(b.id ?? 0))); + } + return map; + }, [filtered]); + + return ( +
+
+
+

Kanban

+

Board view for tasks (quick triage + status moves).

+
+ +
+ + {tasks.error ? ( +
{(tasks.error as Error).message}
+ ) : null} + +
+ + + Filters + Scope the board. + + + + + + +
+ Showing {filtered.length} / {taskList.length} tasks +
+
+
+
+ +
+ {STATUSES.map((status) => ( + + + {status.replaceAll("_", " ")} + {tasksByStatus.get(status)?.length ?? 0} tasks + + + {(tasksByStatus.get(status) ?? []).map((t) => ( +
+
{t.title}
+ {t.description ? ( +
{t.description}
+ ) : null} +
+ #{t.id} · {t.project_id ? `proj ${t.project_id}` : "no project"} + {t.assignee_employee_id != null ? ` · assignee ${employeeNameById.get(t.assignee_employee_id) ?? t.assignee_employee_id}` : ""} +
+ +
+ + + +
+
+ ))} + + {(tasksByStatus.get(status) ?? []).length === 0 ? ( +
No tasks
+ ) : null} +
+
+ ))} +
+ +
+ Tip: set Actor ID in the left sidebar so changes are attributed correctly. +
+
+ ); +}