- 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)
157 lines
5.2 KiB
TypeScript
157 lines
5.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|