docs/web-client-vite/src/components/KanbanBoard.tsx

243 lines
9.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.

import { useEffect, useState } from "react";
import type { Task } from "@/lib/api";
import { getTasks, updateTask } from "@/lib/api";
import TaskModal from "@/components/TaskModal";
import CreateTaskModal from "@/components/CreateTaskModal";
import { wsClient } from "@/lib/ws";
const COLUMNS = [
{ key: "backlog", label: "Backlog", color: "#737373" },
{ key: "todo", label: "TODO", color: "#3b82f6" },
{ key: "in_progress", label: "In Progress", color: "#f59e0b" },
{ key: "in_review", label: "Review", color: "#a855f7" },
{ key: "done", label: "Done", color: "#22c55e" },
];
const PRIORITY_COLORS: Record<string, string> = {
critical: "#ef4444",
high: "#f59e0b",
medium: "#3b82f6",
low: "#737373",
};
interface Props {
projectId: string;
projectSlug: string;
}
export default function KanbanBoard({ projectId, projectSlug }: Props) {
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [draggedTask, setDraggedTask] = useState<string | null>(null);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [createInStatus, setCreateInStatus] = useState<string | null>(null);
const prefix = projectSlug.slice(0, 2).toUpperCase();
const loadTasks = async () => {
try {
const data = await getTasks(projectId);
setTasks(data);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTasks();
// Subscribe to WebSocket events for real-time updates
const unsubscribeCreated = wsClient.on("task.created", (data: Task) => {
if (data.project_id === projectId) {
setTasks((prev) => prev.some((t) => t.id === data.id) ? prev : [...prev, data]);
}
});
const unsubscribeUpdated = wsClient.on("task.updated", (data: Task) => {
if (data.project_id === projectId) {
setTasks((prev) => prev.map((t) => t.id === data.id ? data : t));
}
});
const unsubscribeAssigned = wsClient.on("task.assigned", (data: Task) => {
if (data.project_id === projectId) {
setTasks((prev) => prev.map((t) => t.id === data.id ? data : t));
}
});
return () => {
unsubscribeCreated?.();
unsubscribeUpdated?.();
unsubscribeAssigned?.();
};
}, [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(); }
};
if (loading) {
return <div className="flex items-center justify-center h-64 text-[var(--muted)]">Загрузка...</div>;
}
const TaskCard = ({ task, showMoveButtons, colIndex }: { task: Task; showMoveButtons?: boolean; colIndex?: number }) => (
<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-pointer
hover:border-[var(--accent)] transition-colors"
>
<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.parent_key && <span>{task.parent_key} / </span>}
{prefix}-{task.number}
</span>
{task.assignee && (
<span className="text-xs text-[var(--muted)] ml-auto"> {task.assignee.slug}</span>
)}
</div>
<div className="text-sm ml-3.5">{task.title}</div>
{task.labels && task.labels.length > 0 && (
<div className="flex flex-wrap gap-1 ml-3.5 mt-1">
{task.labels.map((label: string) => (
<span key={label} className="text-[10px] px-1.5 py-0.5 rounded-full bg-[var(--accent)]/20 text-[var(--accent)]">
{label}
</span>
))}
</div>
)}
{showMoveButtons && colIndex !== undefined && (
<div className="flex gap-1 mt-2 ml-3.5">
{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>
);
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 || "backlog") === 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 */}
<div className="md:hidden flex-1 overflow-y-auto p-3">
{(() => {
const col = COLUMNS.find((c) => c.key === (activeColumn || "backlog"))!;
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) => (
<TaskCard key={task.id} task={task} showMoveButtons colIndex={colIndex} />
))}
<button
className="text-sm text-[var(--muted)] hover:text-[var(--fg)] py-2"
onClick={() => setCreateInStatus(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) => (
<TaskCard key={task.id} task={task} />
))}
<button
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] mt-1 text-left px-2 py-1"
onClick={() => setCreateInStatus(col.key)}
>+ Добавить</button>
</div>
</div>
);
})}
</div>
{/* Task modal */}
{selectedTask && (
<TaskModal
task={selectedTask}
projectId={projectId}
projectSlug={projectSlug}
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);
}}
onOpenTask={(taskId) => {
const t = tasks.find(t => t.id === taskId);
if (t) setSelectedTask(t);
}}
/>
)}
{/* Create task modal */}
{createInStatus && (
<CreateTaskModal
projectId={projectId}
initialStatus={createInStatus}
onClose={() => setCreateInStatus(null)}
onCreated={(task) => setTasks((prev) => prev.some((t) => t.id === task.id) ? prev : [...prev, task])}
/>
)}
</div>
);
}