web-client/src/components/KanbanBoard.tsx
Markov eff6724455
Some checks failed
Deploy Web Client / deploy (push) Has been cancelled
fix: stopPropagation on mobile move buttons, prevent modal on move
2026-02-15 23:48:27 +01:00

277 lines
10 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";
import TaskModal from "@/components/TaskModal";
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 [activeColumn, setActiveColumn] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | 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;
setTasks((prev) => prev.map((t) => (t.id === draggedTask ? { ...t, status } : t)));
setDraggedTask(null);
try {
await updateTask(draggedTask, { status });
} catch {
loadTasks();
}
};
const handleMoveTask = async (taskId: string, newStatus: string) => {
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
try {
await updateTask(taskId, { status: newStatus });
} catch {
loadTasks();
}
};
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>;
}
// Mobile: column selector + vertical list
// Desktop: horizontal kanban
return (
<div className="h-full flex flex-col">
{/* Mobile column tabs */}
<div className="md:hidden flex overflow-x-auto border-b border-[var(--border)] px-2 py-1 gap-1 shrink-0">
{COLUMNS.map((col) => {
const count = tasks.filter((t) => t.status === col.key).length;
return (
<button
key={col.key}
onClick={() => setActiveColumn(col.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs whitespace-nowrap shrink-0 transition-colors
${(activeColumn || "draft") === col.key
? "bg-white/10 text-[var(--fg)]"
: "text-[var(--muted)]"
}`}
>
<span className="w-2 h-2 rounded-full" style={{ background: col.color }} />
{col.label}
{count > 0 && <span className="text-[var(--muted)]">{count}</span>}
</button>
);
})}
</div>
{/* Mobile: single column view */}
<div className="md:hidden flex-1 overflow-y-auto p-3">
{(() => {
const col = COLUMNS.find((c) => c.key === (activeColumn || "draft"))!;
const colTasks = tasks.filter((t) => t.status === col.key);
const colIndex = COLUMNS.findIndex((c) => c.key === col.key);
return (
<div className="flex flex-col gap-2">
{colTasks.map((task) => (
<div
key={task.id}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 cursor-pointer
hover:border-[var(--accent)] transition-colors"
onClick={() => setSelectedTask(task)}
>
<div className="flex items-center gap-1.5 mb-1">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }}
/>
<span className="text-xs text-[var(--muted)]">{task.key}</span>
</div>
<div className="text-sm ml-3.5">{task.title}</div>
{/* Move buttons */}
<div className="flex gap-1 mt-2 ml-4">
{colIndex > 0 && (
<button
onClick={(e) => { e.stopPropagation(); handleMoveTask(task.id, COLUMNS[colIndex - 1].key); }}
className="text-xs px-2 py-0.5 rounded bg-white/5 text-[var(--muted)]
hover:text-[var(--fg)]"
>
{COLUMNS[colIndex - 1].label}
</button>
)}
{colIndex < COLUMNS.length - 1 && (
<button
onClick={(e) => { e.stopPropagation(); handleMoveTask(task.id, COLUMNS[colIndex + 1].key); }}
className="text-xs px-2 py-0.5 rounded bg-white/5 text-[var(--muted)]
hover:text-[var(--fg)]"
>
{COLUMNS[colIndex + 1].label}
</button>
)}
</div>
</div>
))}
{addingTo === col.key ? (
<input
autoFocus
className="w-full bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2
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={() => !newTaskTitle.trim() && setAddingTo(null)}
/>
) : (
<button
className="text-sm text-[var(--muted)] hover:text-[var(--fg)] py-2"
onClick={() => setAddingTo(col.key)}
>
+ Добавить задачу
</button>
)}
</div>
);
})()}
</div>
{/* Desktop: horizontal kanban */}
<div className="hidden md:flex gap-4 overflow-x-auto p-4 flex-1">
{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)}
>
<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>
<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)}
onClick={() => setSelectedTask(task)}
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-center gap-1.5 mb-1">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }}
title={task.priority}
/>
<span className="text-xs text-[var(--muted)]">{task.key}</span>
</div>
<div className="text-sm ml-3.5">{task.title}</div>
</div>
))}
{addingTo === col.key ? (
<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={() => !newTaskTitle.trim() && setAddingTo(null)}
/>
) : (
<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>
{/* Task modal */}
{selectedTask && (
<TaskModal
task={selectedTask}
projectId={projectId}
onClose={() => setSelectedTask(null)}
onUpdated={(updated) => {
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setSelectedTask(updated);
}}
onDeleted={(id) => {
setTasks((prev) => prev.filter((t) => t.id !== id));
setSelectedTask(null);
}}
/>
)}
</div>
);
}