From 7185a50f57a4c515ce44297229810ae1375f2600 Mon Sep 17 00:00:00 2001 From: Markov Date: Sun, 22 Feb 2026 17:40:13 +0100 Subject: [PATCH] v0.2.0: updated for new Tracker API - api.ts: Member, Unified Message, Steps, new types - ws.ts: auth.ok handling, project.subscribe, chat.send - KanbanBoard: new statuses (backlog/todo/in_progress/in_review/done) - TaskModal: steps checklist, comments (unified messages), member select - ChatPanel: unified messages, WS events - BFF: all new endpoints (members, messages, steps, task actions) - BFF ws_proxy: token-based auth to tracker --- bff/app.py | 201 +++++++----- bff/ws_proxy.py | 32 +- src/app/(protected)/projects/[slug]/page.tsx | 2 +- src/components/ChatPanel.tsx | 218 ++++--------- src/components/KanbanBoard.tsx | 22 +- src/components/TaskModal.tsx | 305 +++++++++++-------- src/lib/api.ts | 272 +++++++++++------ src/lib/ws.ts | 106 ++++--- 8 files changed, 653 insertions(+), 505 deletions(-) diff --git a/bff/app.py b/bff/app.py index f454ffe..21ec1b2 100644 --- a/bff/app.py +++ b/bff/app.py @@ -3,7 +3,7 @@ import logging import time -from fastapi import FastAPI, Request, Depends +from fastapi import FastAPI, Request, Depends, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from pydantic import BaseModel @@ -19,9 +19,8 @@ logging.basicConfig( ) logger = logging.getLogger("bff") -app = FastAPI(title="Team Board BFF", version="0.1.0") +app = FastAPI(title="Team Board BFF", version="0.2.0") -# CORS app.add_middleware( CORSMiddleware, allow_origins=["https://team.uix.su", "http://localhost:3100"], @@ -30,8 +29,6 @@ app.add_middleware( ) -# --- Request logging --- - @app.middleware("http") async def log_requests(request: Request, call_next): start = time.time() @@ -57,61 +54,68 @@ class LoginRequest(BaseModel): async def login(data: LoginRequest): if data.username == AUTH_USER and data.password == AUTH_PASS: token = create_token(data.username) - return {"token": token, "user": {"name": data.username, "provider": "local"}} + return {"token": token, "user": {"name": data.username, "slug": "admin"}} return JSONResponse({"error": "Invalid credentials"}, status_code=401) @app.get("/api/auth/me") async def me(user: dict = Depends(get_current_user)): - return {"user": {"name": user["name"], "provider": user["provider"]}} + return {"user": {"name": user["name"], "slug": user.get("sub", "admin")}} # --- Proxy: Projects --- -@app.get("/api/v1/projects/") +@app.get("/api/v1/projects") async def list_projects(user: dict = Depends(get_current_user)): - return await tracker_get("/api/v1/projects/") + return await tracker_get("/api/v1/projects") -@app.post("/api/v1/projects/") +@app.post("/api/v1/projects") async def create_project(request: Request, user: dict = Depends(get_current_user)): body = await request.json() - return await tracker_post("/api/v1/projects/", json=body) + return await tracker_post("/api/v1/projects", json=body) -@app.get("/api/v1/projects/{project_id}") -async def get_project(project_id: str, user: dict = Depends(get_current_user)): - return await tracker_get(f"/api/v1/projects/{project_id}") +@app.get("/api/v1/projects/{slug}") +async def get_project(slug: str, user: dict = Depends(get_current_user)): + return await tracker_get(f"/api/v1/projects/{slug}") -@app.patch("/api/v1/projects/{project_id}") -async def update_project(project_id: str, request: Request, user: dict = Depends(get_current_user)): +@app.patch("/api/v1/projects/{slug}") +async def update_project(slug: str, request: Request, user: dict = Depends(get_current_user)): body = await request.json() - return await tracker_patch(f"/api/v1/projects/{project_id}", json=body) + return await tracker_patch(f"/api/v1/projects/{slug}", json=body) -@app.delete("/api/v1/projects/{project_id}") -async def delete_project(project_id: str, user: dict = Depends(get_current_user)): - await tracker_delete(f"/api/v1/projects/{project_id}") - return JSONResponse(status_code=204) +@app.delete("/api/v1/projects/{slug}") +async def delete_project(slug: str, user: dict = Depends(get_current_user)): + return await tracker_delete(f"/api/v1/projects/{slug}") # --- Proxy: Tasks --- -@app.get("/api/v1/tasks/") -async def list_tasks(project_id: str = None, status: str = None, user: dict = Depends(get_current_user)): +@app.get("/api/v1/tasks") +async def list_tasks( + project_id: str = None, status: str = None, + assignee: str = None, label: str = None, + user: dict = Depends(get_current_user), +): params = {} if project_id: params["project_id"] = project_id if status: params["status"] = status - return await tracker_get("/api/v1/tasks/", params=params) + if assignee: + params["assignee"] = assignee + if label: + params["label"] = label + return await tracker_get("/api/v1/tasks", params=params) -@app.post("/api/v1/tasks/") -async def create_task(request: Request, user: dict = Depends(get_current_user)): +@app.post("/api/v1/tasks") +async def create_task(request: Request, project_slug: str = Query(...), user: dict = Depends(get_current_user)): body = await request.json() - return await tracker_post("/api/v1/tasks/", json=body) + return await tracker_post(f"/api/v1/tasks?project_slug={project_slug}", json=body) @app.get("/api/v1/tasks/{task_id}") @@ -127,73 +131,120 @@ async def update_task(task_id: str, request: Request, user: dict = Depends(get_c @app.delete("/api/v1/tasks/{task_id}") async def delete_task(task_id: str, user: dict = Depends(get_current_user)): - await tracker_delete(f"/api/v1/tasks/{task_id}") - return JSONResponse(status_code=204) + return await tracker_delete(f"/api/v1/tasks/{task_id}") -# --- Proxy: Agents --- - -@app.get("/api/v1/agents/") -async def list_agents(user: dict = Depends(get_current_user)): - return await tracker_get("/api/v1/agents/") +@app.post("/api/v1/tasks/{task_id}/take") +async def take_task(task_id: str, slug: str = Query(...), user: dict = Depends(get_current_user)): + return await tracker_post(f"/api/v1/tasks/{task_id}/take?slug={slug}") -# --- Proxy: Chats --- - -@app.get("/api/v1/chats/lobby") -async def get_lobby(user: dict = Depends(get_current_user)): - return await tracker_get("/api/v1/chats/lobby") - - -@app.get("/api/v1/chats/project/{project_id}") -async def get_project_chat(project_id: str, user: dict = Depends(get_current_user)): - return await tracker_get(f"/api/v1/chats/project/{project_id}") - - -@app.get("/api/v1/chats/{chat_id}/messages") -async def list_messages(chat_id: str, limit: int = 50, before: str = None, user: dict = Depends(get_current_user)): - params = {"limit": limit} - if before: - params["before"] = before - return await tracker_get(f"/api/v1/chats/{chat_id}/messages", params=params) - - -@app.post("/api/v1/chats/{chat_id}/messages") -async def create_message(chat_id: str, request: Request, user: dict = Depends(get_current_user)): +@app.post("/api/v1/tasks/{task_id}/reject") +async def reject_task(task_id: str, request: Request, user: dict = Depends(get_current_user)): body = await request.json() - # Inject sender info from auth - body.setdefault("sender_type", "human") - body.setdefault("sender_name", user.get("name", "human")) - return await tracker_post(f"/api/v1/chats/{chat_id}/messages", json=body) + return await tracker_post(f"/api/v1/tasks/{task_id}/reject", json=body) -# --- Proxy: Labels --- - -@app.get("/api/v1/labels/") -async def list_labels(user: dict = Depends(get_current_user)): - return await tracker_get("/api/v1/labels/") - - -@app.post("/api/v1/labels/") -async def create_label(request: Request, user: dict = Depends(get_current_user)): +@app.post("/api/v1/tasks/{task_id}/assign") +async def assign_task(task_id: str, request: Request, user: dict = Depends(get_current_user)): body = await request.json() - return await tracker_post("/api/v1/labels/", json=body) + return await tracker_post(f"/api/v1/tasks/{task_id}/assign", json=body) + + +@app.post("/api/v1/tasks/{task_id}/watch") +async def watch_task(task_id: str, slug: str = Query(...), user: dict = Depends(get_current_user)): + return await tracker_post(f"/api/v1/tasks/{task_id}/watch?slug={slug}") + + +@app.delete("/api/v1/tasks/{task_id}/watch") +async def unwatch_task(task_id: str, slug: str = Query(...), user: dict = Depends(get_current_user)): + return await tracker_delete(f"/api/v1/tasks/{task_id}/watch?slug={slug}") + + +# --- Proxy: Steps --- + +@app.get("/api/v1/tasks/{task_id}/steps") +async def list_steps(task_id: str, user: dict = Depends(get_current_user)): + return await tracker_get(f"/api/v1/tasks/{task_id}/steps") + + +@app.post("/api/v1/tasks/{task_id}/steps") +async def create_step(task_id: str, request: Request, user: dict = Depends(get_current_user)): + body = await request.json() + return await tracker_post(f"/api/v1/tasks/{task_id}/steps", json=body) + + +@app.patch("/api/v1/tasks/{task_id}/steps/{step_id}") +async def update_step(task_id: str, step_id: str, request: Request, user: dict = Depends(get_current_user)): + body = await request.json() + return await tracker_patch(f"/api/v1/tasks/{task_id}/steps/{step_id}", json=body) + + +@app.delete("/api/v1/tasks/{task_id}/steps/{step_id}") +async def delete_step(task_id: str, step_id: str, user: dict = Depends(get_current_user)): + return await tracker_delete(f"/api/v1/tasks/{task_id}/steps/{step_id}") + + +# --- Proxy: Messages (unified) --- + +@app.get("/api/v1/messages") +async def list_messages( + chat_id: str = None, task_id: str = None, + limit: int = 50, offset: int = 0, + user: dict = Depends(get_current_user), +): + params = {"limit": limit, "offset": offset} + if chat_id: + params["chat_id"] = chat_id + if task_id: + params["task_id"] = task_id + return await tracker_get("/api/v1/messages", params=params) + + +@app.post("/api/v1/messages") +async def create_message(request: Request, user: dict = Depends(get_current_user)): + body = await request.json() + # Inject author from auth + body.setdefault("author_type", "human") + body.setdefault("author_slug", user.get("sub", "admin")) + return await tracker_post("/api/v1/messages", json=body) + + +# --- Proxy: Members --- + +@app.get("/api/v1/members") +async def list_members(user: dict = Depends(get_current_user)): + return await tracker_get("/api/v1/members") + + +@app.get("/api/v1/members/{slug}") +async def get_member(slug: str, user: dict = Depends(get_current_user)): + return await tracker_get(f"/api/v1/members/{slug}") + + +@app.post("/api/v1/members") +async def create_member(request: Request, user: dict = Depends(get_current_user)): + body = await request.json() + return await tracker_post("/api/v1/members", json=body) + + +@app.patch("/api/v1/members/{slug}") +async def update_member(slug: str, request: Request, user: dict = Depends(get_current_user)): + body = await request.json() + return await tracker_patch(f"/api/v1/members/{slug}", json=body) # --- Health --- @app.get("/health") async def health(): - return {"status": "ok", "service": "bff", "version": "0.1.0"} + return {"status": "ok", "service": "bff", "version": "0.2.0"} -# --- WebSocket --- - +# WebSocket proxy app.include_router(ws_router) -# --- Shutdown --- - @app.on_event("shutdown") async def shutdown(): await close_client() diff --git a/bff/ws_proxy.py b/bff/ws_proxy.py index 7288eb0..a245f79 100644 --- a/bff/ws_proxy.py +++ b/bff/ws_proxy.py @@ -1,8 +1,8 @@ -"""WebSocket proxy: authenticated users ↔ Tracker.""" +"""WebSocket proxy: authenticated web users ↔ Tracker WS.""" import asyncio -import logging import json +import logging from fastapi import APIRouter, WebSocket, WebSocketDisconnect import websockets @@ -26,12 +26,30 @@ async def ws_proxy(ws: WebSocket, token: str = ""): return await ws.accept() - logger.info("WS connected: %s", user.get("name")) + user_name = user.get("name", "unknown") + user_slug = user.get("sub", "admin") + logger.info("WS connected: %s (%s)", user_name, user_slug) - # Connect to tracker - tracker_url = f"{TRACKER_WS_URL}?client_type=human&client_id={user['sub']}&token={TRACKER_TOKEN}" try: - async with websockets.connect(tracker_url) as tracker_ws: + async with websockets.connect(TRACKER_WS_URL) as tracker_ws: + # Send auth to tracker on behalf of user + auth_msg = json.dumps({ + "type": "auth", + "token": TRACKER_TOKEN, + }) + await tracker_ws.send(auth_msg) + + # Wait for auth.ok + auth_resp = await tracker_ws.recv() + auth_data = json.loads(auth_resp) + if auth_data.get("type") != "auth.ok": + logger.error("Tracker auth failed: %s", auth_data) + await ws.close(code=4002, reason="Tracker auth failed") + return + + # Forward auth.ok to client + await ws.send_text(auth_resp) + async def forward_to_tracker(): try: while True: @@ -51,4 +69,4 @@ async def ws_proxy(ws: WebSocket, token: str = ""): except Exception as e: logger.error("WS proxy error: %s", e) finally: - logger.info("WS disconnected: %s", user.get("name")) + logger.info("WS disconnected: %s", user_name) diff --git a/src/app/(protected)/projects/[slug]/page.tsx b/src/app/(protected)/projects/[slug]/page.tsx index b3185a0..e8a0175 100644 --- a/src/app/(protected)/projects/[slug]/page.tsx +++ b/src/app/(protected)/projects/[slug]/page.tsx @@ -43,7 +43,7 @@ export default function ProjectPage() {
- +
diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 7fefde4..8fd0c75 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1,186 +1,90 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { - Chat, - ChatMessage, - getProjectChat, - getChatMessages, -} from "@/lib/api"; +import { Message, getMessages } from "@/lib/api"; import { wsClient } from "@/lib/ws"; -interface ChatPanelProps { +const AUTHOR_ICON: Record = { + human: "👤", + agent: "🤖", + system: "⚙️", +}; + +interface Props { projectId: string; } -export default function ChatPanel({ projectId }: ChatPanelProps) { - const [chat, setChat] = useState(null); - const [messages, setMessages] = useState([]); +export default function ChatPanel({ projectId }: Props) { + const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); - const [expanded, setExpanded] = useState(false); - const [collapsed, setCollapsed] = useState(false); - const messagesEndRef = useRef(null); - const inputRef = useRef(null); + const [chatId, setChatId] = useState(null); + const [open, setOpen] = useState(false); + const bottomRef = useRef(null); - // Load chat and messages + // Find project chat ID from WS auth data or use lobby useEffect(() => { - let cancelled = false; - getProjectChat(projectId).then((c) => { - if (cancelled) return; - setChat(c); - getChatMessages(c.id).then((msgs) => { - if (!cancelled) setMessages(msgs); - }); - }); - return () => { cancelled = true; }; - }, [projectId]); + // For now use lobby + const id = wsClient.lobbyId; + if (id) { + setChatId(id); + getMessages({ chat_id: id, limit: 50 }).then(setMessages).catch(() => {}); + } + }, []); - // WebSocket: subscribe to chat room + // Listen for new messages useEffect(() => { - if (!chat) return; - - wsClient.connect(); - wsClient.subscribeChat(chat.id); - - const unsub = wsClient.on("chat.message", (msg: any) => { - if (msg.chat_id === chat.id) { - setMessages((prev) => [ - ...prev, - { - id: msg.id, - chat_id: msg.chat_id, - sender_type: msg.sender_type, - sender_id: msg.sender_id, - sender_name: msg.sender_name, - content: msg.content, - created_at: msg.created_at, - }, - ]); + const unsub = wsClient.on("message.new", (data: any) => { + if (data.chat_id === chatId) { + setMessages((prev) => [...prev, data as Message]); } }); + return unsub; + }, [chatId]); - return () => { - wsClient.unsubscribeChat(chat.id); - unsub(); - }; - }, [chat]); - - // Auto-scroll useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messages]); const handleSend = () => { - if (!chat || !input.trim()) return; - const text = input.trim(); + if (!input.trim() || !chatId) return; + wsClient.sendChat(chatId, input.trim()); setInput(""); - - // Send via WebSocket — message will come back via chat.message event - wsClient.send("chat.send", { - chat_id: chat.id, - content: text, - sender_type: "human", - sender_name: "admin", - }); - - inputRef.current?.focus(); }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }; - - if (collapsed) { - return ( -
- -
- ); - } - - const panelHeight = expanded ? "h-full" : "h-[30vh]"; - return ( -
- {/* Header */} -
- 💬 Чат проекта -
- - -
-
+
+ - {/* Messages */} -
- {messages.length === 0 && ( -

- Пока пусто. Напишите первое сообщение 💬 -

- )} - {messages.map((msg) => ( -
- - {msg.sender_name || msg.sender_type}: - - {msg.content} - - {new Date(msg.created_at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })} - + {open && ( +
+
+ {messages.map((msg) => ( +
+ + {AUTHOR_ICON[msg.author_type] || "👤"} {msg.author_slug} + + {msg.content} +
+ ))} +
- ))} -
-
- - {/* Input */} -
- setInput(e.target.value)} - onKeyDown={handleKeyDown} - 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)]" - disabled={!chat} - /> - -
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSend()} + 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)]" + /> + +
+
+ )}
); } diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx index 2cfcc44..a04bfc9 100644 --- a/src/components/KanbanBoard.tsx +++ b/src/components/KanbanBoard.tsx @@ -5,11 +5,11 @@ 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: "backlog", label: "Backlog", color: "#737373" }, + { key: "todo", label: "TODO", color: "#3b82f6" }, { key: "in_progress", label: "In Progress", color: "#f59e0b" }, - { key: "review", label: "Review", color: "#a855f7" }, - { key: "completed", label: "Done", color: "#22c55e" }, + { key: "in_review", label: "Review", color: "#a855f7" }, + { key: "done", label: "Done", color: "#22c55e" }, ]; const PRIORITY_COLORS: Record = { @@ -21,9 +21,10 @@ const PRIORITY_COLORS: Record = { interface Props { projectId: string; + projectSlug: string; } -export default function KanbanBoard({ projectId }: Props) { +export default function KanbanBoard({ projectId, projectSlug }: Props) { const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [newTaskTitle, setNewTaskTitle] = useState(""); @@ -72,8 +73,7 @@ export default function KanbanBoard({ projectId }: Props) { const handleAddTask = async (status: string) => { if (!newTaskTitle.trim()) return; try { - const task = await createTask({ - project_id: projectId, + const task = await createTask(projectSlug, { title: newTaskTitle.trim(), status, }); @@ -102,7 +102,7 @@ export default function KanbanBoard({ projectId }: Props) { 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 + ${(activeColumn || "backlog") === col.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)]" }`} @@ -118,7 +118,7 @@ export default function KanbanBoard({ projectId }: Props) { {/* Mobile: single column view */}
{(() => { - const col = COLUMNS.find((c) => c.key === (activeColumn || "draft"))!; + 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 ( @@ -135,7 +135,7 @@ export default function KanbanBoard({ projectId }: Props) { className="w-2 h-2 rounded-full shrink-0" style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }} /> - {task.key} + {`#${task.number}`}
{task.title}
{/* Move buttons */} @@ -222,7 +222,7 @@ export default function KanbanBoard({ projectId }: Props) { style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }} title={task.priority} /> - {task.key} + {`#${task.number}`}
{task.title}
diff --git a/src/components/TaskModal.tsx b/src/components/TaskModal.tsx index 56081e7..5ecf03b 100644 --- a/src/components/TaskModal.tsx +++ b/src/components/TaskModal.tsx @@ -1,15 +1,19 @@ "use client"; import { useEffect, useState } from "react"; -import { Task, Agent, updateTask, deleteTask, getAgents, getTasks } from "@/lib/api"; +import { + Task, Member, Step, Message, + updateTask, deleteTask, getMembers, + getSteps, createStep, updateStep, deleteStep as deleteStepApi, + getMessages, sendMessage, +} from "@/lib/api"; const STATUSES = [ - { key: "draft", label: "Backlog", color: "#737373" }, - { key: "ready", label: "TODO", color: "#3b82f6" }, + { key: "backlog", label: "Backlog", color: "#737373" }, + { key: "todo", label: "TODO", color: "#3b82f6" }, { key: "in_progress", label: "In Progress", color: "#f59e0b" }, - { key: "review", label: "Review", color: "#a855f7" }, - { key: "completed", label: "Done", color: "#22c55e" }, - { key: "blocked", label: "Blocked", color: "#ef4444" }, + { key: "in_review", label: "Review", color: "#a855f7" }, + { key: "done", label: "Done", color: "#22c55e" }, ]; const PRIORITIES = [ @@ -19,6 +23,12 @@ const PRIORITIES = [ { key: "critical", label: "Critical", color: "#ef4444" }, ]; +const AUTHOR_ICON: Record = { + human: "👤", + agent: "🤖", + system: "⚙️", +}; + interface Props { task: Task; projectId: string; @@ -32,18 +42,22 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet const [description, setDescription] = useState(task.description || ""); const [status, setStatus] = useState(task.status); const [priority, setPriority] = useState(task.priority); - const [assignedAgentId, setAssignedAgentId] = useState(task.assigned_agent_id || ""); - const [agents, setAgents] = useState([]); - const [allTasks, setAllTasks] = useState([]); + const [assigneeSlug, setAssigneeSlug] = useState(task.assignee_slug || ""); + const [members, setMembers] = useState([]); + const [steps, setSteps] = useState(task.steps || []); + const [comments, setComments] = useState([]); const [saving, setSaving] = useState(false); const [editingDesc, setEditingDesc] = useState(false); const [editingTitle, setEditingTitle] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); + const [newStep, setNewStep] = useState(""); + const [newComment, setNewComment] = useState(""); useEffect(() => { - getAgents().then(setAgents).catch(() => {}); - getTasks(projectId).then((t) => setAllTasks(t.filter((x) => x.id !== task.id))).catch(() => {}); - }, []); + getMembers().then(setMembers).catch(() => {}); + getSteps(task.id).then(setSteps).catch(() => {}); + getMessages({ task_id: task.id }).then(setComments).catch(() => {}); + }, [task.id]); const save = async (patch: Partial) => { setSaving(true); @@ -67,12 +81,42 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet } }; - const currentStatus = STATUSES.find((s) => s.key === status); - const currentPriority = PRIORITIES.find((p) => p.key === priority); + const handleAddStep = async () => { + if (!newStep.trim()) return; + try { + const step = await createStep(task.id, newStep.trim()); + setSteps((prev) => [...prev, step]); + setNewStep(""); + } catch (e) { + console.error(e); + } + }; + + const handleToggleStep = async (step: Step) => { + try { + const updated = await updateStep(task.id, step.id, { done: !step.done }); + setSteps((prev) => prev.map((s) => (s.id === step.id ? updated : s))); + } catch (e) { + console.error(e); + } + }; + + const handleAddComment = async () => { + if (!newComment.trim()) return; + try { + const msg = await sendMessage({ task_id: task.id, content: newComment.trim() }); + setComments((prev) => [...prev, msg]); + setNewComment(""); + } catch (e) { + console.error(e); + } + }; return ( -
+
e.stopPropagation()} @@ -92,8 +136,7 @@ export default function TaskModal({ task, projectId, onClose, onUpdated, onDelet if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") { setTitle(task.title); setEditingTitle(false); } }} - className="w-full text-xl font-bold bg-transparent border-b border-[var(--accent)] - outline-none pb-1" + className="w-full text-xl font-bold bg-transparent border-b border-[var(--accent)] outline-none pb-1" /> ) : (

)} -
{task.key || task.id.slice(0, 8)}
+
#{task.number}

- {/* Left: Description */} -
-
- Описание - {!editingDesc && ( - + {/* Left: Description + Steps + Comments */} +
+ {/* Description */} +
+
+ Описание + {!editingDesc && ( + + )} +
+ {editingDesc ? ( +
+