feat: task key prefix (TE-1), create task modal, chat via REST
All checks were successful
Deploy Web Client / deploy (push) Successful in 34s
All checks were successful
Deploy Web Client / deploy (push) Successful in 34s
- Task keys: PROJECT_SLUG[:2]-NUMBER (e.g. TE-1) - Create task: proper modal with title, description, priority - Chat: sends via REST API (not WS), uses project chat_id - Assignee shown on task cards
This commit is contained in:
parent
5e02200510
commit
9ebddce01f
@ -41,7 +41,7 @@ export default function ProjectPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<ChatPanel projectId={project.id} />
|
{project.chat_id && <ChatPanel chatId={project.chat_id} />}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
|
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Message, getMessages } from "@/lib/api";
|
import { Message, getMessages, sendMessage } from "@/lib/api";
|
||||||
import { wsClient } from "@/lib/ws";
|
import { wsClient } from "@/lib/ws";
|
||||||
|
|
||||||
const AUTHOR_ICON: Record<string, string> = {
|
const AUTHOR_ICON: Record<string, string> = {
|
||||||
@ -11,27 +11,23 @@ const AUTHOR_ICON: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
chatId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatPanel({ projectId }: Props) {
|
export default function ChatPanel({ chatId }: Props) {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [chatId, setChatId] = useState<string | null>(null);
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Find project chat ID from WS auth data or use lobby
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// For now use lobby
|
if (chatId) {
|
||||||
const id = wsClient.lobbyId;
|
getMessages({ chat_id: chatId, limit: 50 }).then(setMessages).catch(() => {});
|
||||||
if (id) {
|
|
||||||
setChatId(id);
|
|
||||||
getMessages({ chat_id: id, limit: 50 }).then(setMessages).catch(() => {});
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, [chatId]);
|
||||||
|
|
||||||
// Listen for new messages
|
// Listen for new messages via WS
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = wsClient.on("message.new", (data: any) => {
|
const unsub = wsClient.on("message.new", (data: any) => {
|
||||||
if (data.chat_id === chatId) {
|
if (data.chat_id === chatId) {
|
||||||
@ -45,10 +41,20 @@ export default function ChatPanel({ projectId }: Props) {
|
|||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = async () => {
|
||||||
if (!input.trim() || !chatId) return;
|
if (!input.trim() || !chatId || sending) return;
|
||||||
wsClient.sendChat(chatId, input.trim());
|
const text = input.trim();
|
||||||
setInput("");
|
setInput("");
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const msg = await sendMessage({ chat_id: chatId, content: text });
|
||||||
|
setMessages((prev) => [...prev, msg]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to send:", e);
|
||||||
|
setInput(text); // restore on error
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -71,6 +77,9 @@ export default function ChatPanel({ projectId }: Props) {
|
|||||||
<span className="ml-2">{msg.content}</span>
|
<span className="ml-2">{msg.content}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-sm text-[var(--muted)] italic py-4 text-center">Нет сообщений</div>
|
||||||
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-2 border-t border-[var(--border)] flex gap-2">
|
<div className="px-4 py-2 border-t border-[var(--border)] flex gap-2">
|
||||||
@ -81,7 +90,7 @@ export default function ChatPanel({ projectId }: Props) {
|
|||||||
placeholder="Сообщение..."
|
placeholder="Сообщение..."
|
||||||
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-3 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
|
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-3 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
|
||||||
/>
|
/>
|
||||||
<button onClick={handleSend} className="text-sm text-[var(--accent)] px-2">↵</button>
|
<button onClick={handleSend} disabled={sending} className="text-sm text-[var(--accent)] px-2 disabled:opacity-50">↵</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
112
src/components/CreateTaskModal.tsx
Normal file
112
src/components/CreateTaskModal.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { createTask, Task } from "@/lib/api";
|
||||||
|
|
||||||
|
const PRIORITIES = [
|
||||||
|
{ key: "low", label: "Low", color: "#737373" },
|
||||||
|
{ key: "medium", label: "Medium", color: "#3b82f6" },
|
||||||
|
{ key: "high", label: "High", color: "#f59e0b" },
|
||||||
|
{ key: "critical", label: "Critical", color: "#ef4444" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectSlug: string;
|
||||||
|
initialStatus: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (task: Task) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateTaskModal({ projectSlug, initialStatus, onClose, onCreated }: Props) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [priority, setPriority] = useState("medium");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const task = await createTask(projectSlug, {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
status: initialStatus,
|
||||||
|
priority,
|
||||||
|
});
|
||||||
|
onCreated(task);
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || "Ошибка");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-md p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-bold mb-4">Новая задача</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--muted)] mb-1 block">Название</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
||||||
|
placeholder="Название задачи..."
|
||||||
|
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--muted)] mb-1 block">Описание</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Описание (опционально)..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--accent)] resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--muted)] mb-1 block">Приоритет</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
onClick={() => setPriority(p.key)}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
|
||||||
|
${priority === p.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ background: p.color }} />
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-xs text-red-400">{error}</div>}
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--muted)]">Отмена</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!title.trim() || saving}
|
||||||
|
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "..." : "Создать"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Task, getTasks, updateTask, createTask } from "@/lib/api";
|
import { Task, getTasks, updateTask } from "@/lib/api";
|
||||||
import TaskModal from "@/components/TaskModal";
|
import TaskModal from "@/components/TaskModal";
|
||||||
|
import CreateTaskModal from "@/components/CreateTaskModal";
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ key: "backlog", label: "Backlog", color: "#737373" },
|
{ key: "backlog", label: "Backlog", color: "#737373" },
|
||||||
@ -27,11 +28,12 @@ interface Props {
|
|||||||
export default function KanbanBoard({ projectId, projectSlug }: Props) {
|
export default function KanbanBoard({ projectId, projectSlug }: Props) {
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
|
||||||
const [addingTo, setAddingTo] = useState<string | null>(null);
|
|
||||||
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
||||||
const [activeColumn, setActiveColumn] = useState<string | null>(null);
|
const [activeColumn, setActiveColumn] = useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | 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 () => {
|
const loadTasks = async () => {
|
||||||
try {
|
try {
|
||||||
@ -42,55 +44,64 @@ export default function KanbanBoard({ projectId, projectSlug }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadTasks(); }, [projectId]);
|
||||||
loadTasks();
|
|
||||||
}, [projectId]);
|
|
||||||
|
|
||||||
const handleDrop = async (status: string) => {
|
const handleDrop = async (status: string) => {
|
||||||
if (!draggedTask) return;
|
if (!draggedTask) return;
|
||||||
const task = tasks.find((t) => t.id === draggedTask);
|
const task = tasks.find((t) => t.id === draggedTask);
|
||||||
if (!task || task.status === status) return;
|
if (!task || task.status === status) return;
|
||||||
|
|
||||||
setTasks((prev) => prev.map((t) => (t.id === draggedTask ? { ...t, status } : t)));
|
setTasks((prev) => prev.map((t) => (t.id === draggedTask ? { ...t, status } : t)));
|
||||||
setDraggedTask(null);
|
setDraggedTask(null);
|
||||||
|
try { await updateTask(draggedTask, { status }); } catch { loadTasks(); }
|
||||||
try {
|
|
||||||
await updateTask(draggedTask, { status });
|
|
||||||
} catch {
|
|
||||||
loadTasks();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMoveTask = async (taskId: string, newStatus: string) => {
|
const handleMoveTask = async (taskId: string, newStatus: string) => {
|
||||||
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
|
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
|
||||||
try {
|
try { await updateTask(taskId, { status: newStatus }); } catch { loadTasks(); }
|
||||||
await updateTask(taskId, { status: newStatus });
|
|
||||||
} catch {
|
|
||||||
loadTasks();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddTask = async (status: string) => {
|
|
||||||
if (!newTaskTitle.trim()) return;
|
|
||||||
try {
|
|
||||||
const task = await createTask(projectSlug, {
|
|
||||||
title: newTaskTitle.trim(),
|
|
||||||
status,
|
|
||||||
});
|
|
||||||
setTasks((prev) => [...prev, task]);
|
|
||||||
setNewTaskTitle("");
|
|
||||||
setAddingTo(null);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="flex items-center justify-center h-64 text-[var(--muted)]">Загрузка...</div>;
|
return <div className="flex items-center justify-center h-64 text-[var(--muted)]">Загрузка...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile: column selector + vertical list
|
const TaskCard = ({ task, showMoveButtons, colIndex }: { task: Task; showMoveButtons?: boolean; colIndex?: number }) => (
|
||||||
// Desktop: horizontal kanban
|
<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)]">{prefix}-{task.number}</span>
|
||||||
|
{task.assignee_slug && (
|
||||||
|
<span className="text-xs text-[var(--muted)] ml-auto">→ {task.assignee_slug}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm ml-3.5">{task.title}</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 (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Mobile column tabs */}
|
{/* Mobile column tabs */}
|
||||||
@ -98,15 +109,9 @@ export default function KanbanBoard({ projectId, projectSlug }: Props) {
|
|||||||
{COLUMNS.map((col) => {
|
{COLUMNS.map((col) => {
|
||||||
const count = tasks.filter((t) => t.status === col.key).length;
|
const count = tasks.filter((t) => t.status === col.key).length;
|
||||||
return (
|
return (
|
||||||
<button
|
<button key={col.key} onClick={() => setActiveColumn(col.key)}
|
||||||
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
|
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
|
${(activeColumn || "backlog") === col.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)]"}`}>
|
||||||
? "bg-white/10 text-[var(--fg)]"
|
|
||||||
: "text-[var(--muted)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="w-2 h-2 rounded-full" style={{ background: col.color }} />
|
<span className="w-2 h-2 rounded-full" style={{ background: col.color }} />
|
||||||
{col.label}
|
{col.label}
|
||||||
{count > 0 && <span className="text-[var(--muted)]">{count}</span>}
|
{count > 0 && <span className="text-[var(--muted)]">{count}</span>}
|
||||||
@ -115,7 +120,7 @@ export default function KanbanBoard({ projectId, projectSlug }: Props) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: single column view */}
|
{/* Mobile: single column */}
|
||||||
<div className="md:hidden flex-1 overflow-y-auto p-3">
|
<div className="md:hidden flex-1 overflow-y-auto p-3">
|
||||||
{(() => {
|
{(() => {
|
||||||
const col = COLUMNS.find((c) => c.key === (activeColumn || "backlog"))!;
|
const col = COLUMNS.find((c) => c.key === (activeColumn || "backlog"))!;
|
||||||
@ -124,66 +129,12 @@ export default function KanbanBoard({ projectId, projectSlug }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{colTasks.map((task) => (
|
{colTasks.map((task) => (
|
||||||
<div
|
<TaskCard key={task.id} task={task} showMoveButtons colIndex={colIndex} />
|
||||||
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.number}`}</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
|
<button
|
||||||
className="text-sm text-[var(--muted)] hover:text-[var(--fg)] py-2"
|
className="text-sm text-[var(--muted)] hover:text-[var(--fg)] py-2"
|
||||||
onClick={() => setAddingTo(col.key)}
|
onClick={() => setCreateInStatus(col.key)}
|
||||||
>
|
>+ Добавить задачу</button>
|
||||||
+ Добавить задачу
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@ -194,72 +145,33 @@ export default function KanbanBoard({ projectId, projectSlug }: Props) {
|
|||||||
{COLUMNS.map((col) => {
|
{COLUMNS.map((col) => {
|
||||||
const colTasks = tasks.filter((t) => t.status === col.key);
|
const colTasks = tasks.filter((t) => t.status === col.key);
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={col.key} className="flex flex-col min-w-[280px] w-[280px] shrink-0"
|
||||||
key={col.key}
|
onDragOver={(e) => e.preventDefault()} onDrop={() => handleDrop(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="flex items-center gap-2 mb-3 px-1">
|
||||||
<div className="w-3 h-3 rounded-full" style={{ background: col.color }} />
|
<div className="w-3 h-3 rounded-full" style={{ background: col.color }} />
|
||||||
<span className="font-semibold text-sm">{col.label}</span>
|
<span className="font-semibold text-sm">{col.label}</span>
|
||||||
<span className="text-xs text-[var(--muted)] ml-auto">{colTasks.length}</span>
|
<span className="text-xs text-[var(--muted)] ml-auto">{colTasks.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 flex-1 min-h-[100px] rounded-lg bg-[var(--bg)] p-2">
|
<div className="flex flex-col gap-2 flex-1 min-h-[100px] rounded-lg bg-[var(--bg)] p-2">
|
||||||
{colTasks.map((task) => (
|
{colTasks.map((task) => (
|
||||||
<div
|
<TaskCard key={task.id} task={task} />
|
||||||
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.number}`}</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
|
<button
|
||||||
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] mt-1 text-left px-2 py-1"
|
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] mt-1 text-left px-2 py-1"
|
||||||
onClick={() => setAddingTo(col.key)}
|
onClick={() => setCreateInStatus(col.key)}
|
||||||
>
|
>+ Добавить</button>
|
||||||
+ Добавить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task modal */}
|
{/* Task modal */}
|
||||||
{selectedTask && (
|
{selectedTask && (
|
||||||
<TaskModal
|
<TaskModal
|
||||||
task={selectedTask}
|
task={selectedTask}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
projectSlug={projectSlug}
|
||||||
onClose={() => setSelectedTask(null)}
|
onClose={() => setSelectedTask(null)}
|
||||||
onUpdated={(updated) => {
|
onUpdated={(updated) => {
|
||||||
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||||
@ -271,6 +183,16 @@ export default function KanbanBoard({ projectId, projectSlug }: Props) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create task modal */}
|
||||||
|
{createInStatus && (
|
||||||
|
<CreateTaskModal
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
initialStatus={createInStatus}
|
||||||
|
onClose={() => setCreateInStatus(null)}
|
||||||
|
onCreated={(task) => setTasks((prev) => [...prev, task])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,12 +32,13 @@ const AUTHOR_ICON: Record<string, string> = {
|
|||||||
interface Props {
|
interface Props {
|
||||||
task: Task;
|
task: Task;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
projectSlug: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdated: (task: Task) => void;
|
onUpdated: (task: Task) => void;
|
||||||
onDeleted: (taskId: string) => void;
|
onDeleted: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TaskModal({ task, projectId, onClose, onUpdated, onDeleted }: Props) {
|
export default function TaskModal({ task, projectId, projectSlug, onClose, onUpdated, onDeleted }: Props) {
|
||||||
const [title, setTitle] = useState(task.title);
|
const [title, setTitle] = useState(task.title);
|
||||||
const [description, setDescription] = useState(task.description || "");
|
const [description, setDescription] = useState(task.description || "");
|
||||||
const [status, setStatus] = useState(task.status);
|
const [status, setStatus] = useState(task.status);
|
||||||
@ -146,7 +147,7 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet
|
|||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-[var(--muted)] mt-1">#{task.number}</div>
|
<div className="text-xs text-[var(--muted)] mt-1">{projectSlug.slice(0, 2).toUpperCase()}-{task.number}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row">
|
<div className="flex flex-col md:flex-row">
|
||||||
|
|||||||
@ -64,6 +64,7 @@ export interface Project {
|
|||||||
repo_urls: string[];
|
repo_urls: string[];
|
||||||
status: string;
|
status: string;
|
||||||
task_counter: number;
|
task_counter: number;
|
||||||
|
chat_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Step {
|
export interface Step {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user