feat(tasks): add kanban board page
This commit is contained in:
@@ -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" },
|
||||
|
||||
191
frontend/src/app/kanban/page.tsx
Normal file
191
frontend/src/app/kanban/page.tsx
Normal file
@@ -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<string>("");
|
||||
const [assigneeId, setAssigneeId] = useState<string>("");
|
||||
|
||||
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<number, string>();
|
||||
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 (
|
||||
<main className="mx-auto max-w-screen-2xl p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Kanban</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Board view for tasks (quick triage + status moves).</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
tasks.refetch();
|
||||
projects.refetch();
|
||||
employees.refetch();
|
||||
}}
|
||||
disabled={tasks.isFetching || projects.isFetching || employees.isFetching}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tasks.error ? (
|
||||
<div className="mt-4 text-sm text-destructive">{(tasks.error as Error).message}</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Filters</CardTitle>
|
||||
<CardDescription>Scope the board.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Select value={projectId} onChange={(e) => setProjectId(e.target.value)}>
|
||||
<option value="">All projects</option>
|
||||
{projectList.map((p) => (
|
||||
<option key={p.id ?? p.name} value={p.id ?? ""}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)}>
|
||||
<option value="">All assignees</option>
|
||||
{employeeList.map((e) => (
|
||||
<option key={e.id ?? e.name} value={e.id ?? ""}>
|
||||
{e.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Showing {filtered.length} / {taskList.length} tasks
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4" style={{ gridTemplateColumns: `repeat(${STATUSES.length}, minmax(260px, 1fr))` }}>
|
||||
{STATUSES.map((status) => (
|
||||
<Card key={status} className="min-w-[260px]">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm uppercase tracking-wide">{status.replaceAll("_", " ")}</CardTitle>
|
||||
<CardDescription>{tasksByStatus.get(status)?.length ?? 0} tasks</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{(tasksByStatus.get(status) ?? []).map((t) => (
|
||||
<div key={t.id ?? t.title} className="rounded-md border p-2 text-sm">
|
||||
<div className="font-medium">{t.title}</div>
|
||||
{t.description ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground line-clamp-3">{t.description}</div>
|
||||
) : null}
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
#{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}` : ""}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Select
|
||||
value={t.status ?? "backlog"}
|
||||
onChange={(e) =>
|
||||
updateTask.mutate({
|
||||
taskId: Number(t.id),
|
||||
data: {
|
||||
status: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
disabled={!t.id || updateTask.isPending}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
// quick move right
|
||||
const idx = STATUSES.indexOf(status);
|
||||
const next = STATUSES[Math.min(STATUSES.length - 1, idx + 1)];
|
||||
if (!t.id) return;
|
||||
updateTask.mutate({ taskId: Number(t.id), data: { status: next } });
|
||||
}}
|
||||
disabled={!t.id || updateTask.isPending}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(tasksByStatus.get(status) ?? []).length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No tasks</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs text-muted-foreground">
|
||||
Tip: set Actor ID in the left sidebar so changes are attributed correctly.
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user