243 lines
9.2 KiB
TypeScript
243 lines
9.2 KiB
TypeScript
|
||
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>
|
||
);
|
||
}
|