feat: task key prefix (TE-1), create task modal, chat via REST
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:
Markov 2026-02-22 19:03:24 +01:00
parent 5e02200510
commit 9ebddce01f
6 changed files with 215 additions and 170 deletions

View File

@ -41,7 +41,7 @@ export default function ProjectPage() {
)}
</div>
</header>
<ChatPanel projectId={project.id} />
{project.chat_id && <ChatPanel chatId={project.chat_id} />}
<div className="flex-1 overflow-hidden">
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
</div>

View File

@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Message, getMessages } from "@/lib/api";
import { Message, getMessages, sendMessage } from "@/lib/api";
import { wsClient } from "@/lib/ws";
const AUTHOR_ICON: Record<string, string> = {
@ -11,27 +11,23 @@ const AUTHOR_ICON: Record<string, string> = {
};
interface Props {
projectId: string;
chatId: string;
}
export default function ChatPanel({ projectId }: Props) {
export default function ChatPanel({ chatId }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [chatId, setChatId] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
// Find project chat ID from WS auth data or use lobby
useEffect(() => {
// For now use lobby
const id = wsClient.lobbyId;
if (id) {
setChatId(id);
getMessages({ chat_id: id, limit: 50 }).then(setMessages).catch(() => {});
if (chatId) {
getMessages({ chat_id: chatId, limit: 50 }).then(setMessages).catch(() => {});
}
}, []);
}, [chatId]);
// Listen for new messages
// Listen for new messages via WS
useEffect(() => {
const unsub = wsClient.on("message.new", (data: any) => {
if (data.chat_id === chatId) {
@ -45,10 +41,20 @@ export default function ChatPanel({ projectId }: Props) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = () => {
if (!input.trim() || !chatId) return;
wsClient.sendChat(chatId, input.trim());
const handleSend = async () => {
if (!input.trim() || !chatId || sending) return;
const text = input.trim();
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 (
@ -71,6 +77,9 @@ export default function ChatPanel({ projectId }: Props) {
<span className="ml-2">{msg.content}</span>
</div>
))}
{messages.length === 0 && (
<div className="text-sm text-[var(--muted)] italic py-4 text-center">Нет сообщений</div>
)}
<div ref={bottomRef} />
</div>
<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="Сообщение..."
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>
)}

View 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>
);
}

View File

@ -1,8 +1,9 @@
"use client";
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 CreateTaskModal from "@/components/CreateTaskModal";
const COLUMNS = [
{ key: "backlog", label: "Backlog", color: "#737373" },
@ -27,11 +28,12 @@ interface Props {
export default function KanbanBoard({ projectId, projectSlug }: 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 [createInStatus, setCreateInStatus] = useState<string | null>(null);
const prefix = projectSlug.slice(0, 2).toUpperCase();
const loadTasks = async () => {
try {
@ -42,55 +44,64 @@ export default function KanbanBoard({ projectId, projectSlug }: Props) {
}
};
useEffect(() => {
loadTasks();
}, [projectId]);
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();
}
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(projectSlug, {
title: newTaskTitle.trim(),
status,
});
setTasks((prev) => [...prev, task]);
setNewTaskTitle("");
setAddingTo(null);
} catch (e) {
console.error(e);
}
try { await updateTask(taskId, { status: newStatus }); } catch { loadTasks(); }
};
if (loading) {
return <div className="flex items-center justify-center h-64 text-[var(--muted)]">Загрузка...</div>;
}
// Mobile: column selector + vertical list
// Desktop: horizontal kanban
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)]">{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 (
<div className="h-full flex flex-col">
{/* Mobile column tabs */}
@ -98,15 +109,9 @@ export default function KanbanBoard({ projectId, projectSlug }: Props) {
{COLUMNS.map((col) => {
const count = tasks.filter((t) => t.status === col.key).length;
return (
<button
key={col.key}
onClick={() => setActiveColumn(col.key)}
<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)]"
}`}
>
${(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>}
@ -115,7 +120,7 @@ export default function KanbanBoard({ projectId, projectSlug }: Props) {
})}
</div>
{/* Mobile: single column view */}
{/* Mobile: single column */}
<div className="md:hidden flex-1 overflow-y-auto p-3">
{(() => {
const col = COLUMNS.find((c) => c.key === (activeColumn || "backlog"))!;
@ -124,66 +129,12 @@ export default function KanbanBoard({ projectId, projectSlug }: Props) {
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.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>
<TaskCard key={task.id} task={task} showMoveButtons colIndex={colIndex} />
))}
{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>
)}
onClick={() => setCreateInStatus(col.key)}
>+ Добавить задачу</button>
</div>
);
})()}
@ -194,72 +145,33 @@ export default function KanbanBoard({ projectId, projectSlug }: Props) {
{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 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.number}`}</span>
</div>
<div className="text-sm ml-3.5">{task.title}</div>
</div>
<TaskCard key={task.id} task={task} />
))}
{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>
)}
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)));
@ -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>
);
}

View File

@ -32,12 +32,13 @@ const AUTHOR_ICON: Record<string, string> = {
interface Props {
task: Task;
projectId: string;
projectSlug: string;
onClose: () => void;
onUpdated: (task: Task) => 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 [description, setDescription] = useState(task.description || "");
const [status, setStatus] = useState(task.status);
@ -146,7 +147,7 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet
{title}
</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 className="flex flex-col md:flex-row">

View File

@ -64,6 +64,7 @@ export interface Project {
repo_urls: string[];
status: string;
task_counter: number;
chat_id: string | null;
}
export interface Step {