web-client/src/components/KanbanBoard.tsx
Markov 1d998dca55 feat: Next.js frontend — kanban board, sidebar, dark theme
- Kanban with drag-and-drop (5 columns)
- Project sidebar navigation
- API client (projects, tasks, agents, labels)
- Tailwind CSS dark theme
- Docker support, SSR with internal API URL
- Port 3100 (3000 occupied by Gitea)
2026-02-15 18:52:49 +01:00

157 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import { Task, getTasks, updateTask, createTask } from "@/lib/api";
const COLUMNS = [
{ key: "draft", label: "Backlog", color: "#737373" },
{ key: "ready", label: "TODO", color: "#3b82f6" },
{ key: "in_progress", label: "In Progress", color: "#f59e0b" },
{ key: "review", label: "Review", color: "#a855f7" },
{ key: "completed", label: "Done", color: "#22c55e" },
];
const PRIORITY_COLORS: Record<string, string> = {
critical: "#ef4444",
high: "#f59e0b",
medium: "#3b82f6",
low: "#737373",
};
interface Props {
projectId: string;
}
export default function KanbanBoard({ projectId }: Props) {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [newTaskTitle, setNewTaskTitle] = useState("");
const [addingTo, setAddingTo] = useState<string | null>(null);
const [draggedTask, setDraggedTask] = useState<string | null>(null);
const loadTasks = async () => {
try {
const data = await getTasks(projectId);
setTasks(data);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTasks();
}, [projectId]);
const handleDrop = async (status: string) => {
if (!draggedTask) return;
const task = tasks.find((t) => t.id === draggedTask);
if (!task || task.status === status) return;
// Optimistic update
setTasks((prev) => prev.map((t) => (t.id === draggedTask ? { ...t, status } : t)));
setDraggedTask(null);
try {
await updateTask(draggedTask, { status });
} catch {
loadTasks(); // revert
}
};
const handleAddTask = async (status: string) => {
if (!newTaskTitle.trim()) return;
try {
const task = await createTask({
project_id: projectId,
title: newTaskTitle.trim(),
status,
});
setTasks((prev) => [...prev, task]);
setNewTaskTitle("");
setAddingTo(null);
} catch (e) {
console.error(e);
}
};
if (loading) {
return <div className="flex items-center justify-center h-64 text-[var(--muted)]">Загрузка...</div>;
}
return (
<div className="flex gap-4 overflow-x-auto p-4 h-full">
{COLUMNS.map((col) => {
const colTasks = tasks.filter((t) => t.status === col.key);
return (
<div
key={col.key}
className="flex flex-col min-w-[280px] w-[280px] shrink-0"
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDrop(col.key)}
>
{/* Column header */}
<div className="flex items-center gap-2 mb-3 px-1">
<div className="w-3 h-3 rounded-full" style={{ background: col.color }} />
<span className="font-semibold text-sm">{col.label}</span>
<span className="text-xs text-[var(--muted)] ml-auto">{colTasks.length}</span>
</div>
{/* Cards */}
<div className="flex flex-col gap-2 flex-1 min-h-[100px] rounded-lg bg-[var(--bg)] p-2">
{colTasks.map((task) => (
<div
key={task.id}
draggable
onDragStart={() => setDraggedTask(task.id)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 cursor-grab
hover:border-[var(--accent)] transition-colors active:cursor-grabbing"
>
<div className="flex items-start gap-2">
<div
className="w-2 h-2 rounded-full mt-1.5 shrink-0"
style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }}
title={task.priority}
/>
<span className="text-sm">{task.title}</span>
</div>
{task.description && (
<p className="text-xs text-[var(--muted)] mt-1 ml-4 line-clamp-2">{task.description}</p>
)}
</div>
))}
{/* Add task */}
{addingTo === col.key ? (
<div className="mt-1">
<input
autoFocus
className="w-full bg-[var(--card)] border border-[var(--border)] rounded px-2 py-1.5
text-sm outline-none focus:border-[var(--accent)]"
placeholder="Название задачи..."
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddTask(col.key);
if (e.key === "Escape") setAddingTo(null);
}}
onBlur={() => {
if (!newTaskTitle.trim()) setAddingTo(null);
}}
/>
</div>
) : (
<button
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] mt-1 text-left px-2 py-1"
onClick={() => setAddingTo(col.key)}
>
+ Добавить
</button>
)}
</div>
</div>
);
})}
</div>
);
}