fix: restore all original components (KanbanBoard, ChatPanel, TaskModal, etc.)
Previous migration stubbed most components. Now properly ported from Next.js with type-only imports and react-router-dom Link/navigation.
This commit is contained in:
parent
83bc168c30
commit
3a2a32a50e
@ -1,25 +1,254 @@
|
|||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import type { Member } from "@/lib/api";
|
import type { Member } from "@/lib/api";
|
||||||
|
import { updateMember, regenerateToken, revokeToken } from "@/lib/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agent: Member;
|
agent: Member;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdated: () => void;
|
onUpdated: (agent: Member) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentModal({ agent, onClose, onUpdated: _onUpdated }: Props) {
|
export default function AgentModal({ agent, onClose, onUpdated }: Props) {
|
||||||
|
const [name, setName] = useState(agent.name);
|
||||||
|
const [capabilities, setCapabilities] = useState(
|
||||||
|
agent.agent_config?.capabilities?.join(", ") || ""
|
||||||
|
);
|
||||||
|
const [chatListen, setChatListen] = useState(agent.agent_config?.chat_listen || "mentions");
|
||||||
|
const [taskListen, setTaskListen] = useState(agent.agent_config?.task_listen || "assigned");
|
||||||
|
const [prompt, setPrompt] = useState(agent.agent_config?.prompt || "");
|
||||||
|
const [model, setModel] = useState(agent.agent_config?.model || "");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [currentToken, setCurrentToken] = useState<string | null>(agent.token || null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [confirmRegen, setConfirmRegen] = useState(false);
|
||||||
|
const [confirmRevoke, setConfirmRevoke] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const caps = capabilities.split(",").map((c) => c.trim()).filter(Boolean);
|
||||||
|
const updated = await updateMember(agent.slug, {
|
||||||
|
name: name.trim(),
|
||||||
|
agent_config: {
|
||||||
|
capabilities: caps,
|
||||||
|
chat_listen: chatListen,
|
||||||
|
task_listen: taskListen,
|
||||||
|
prompt: prompt.trim() || null,
|
||||||
|
model: model.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onUpdated(updated);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerate = async () => {
|
||||||
|
try {
|
||||||
|
const { token } = await regenerateToken(agent.slug);
|
||||||
|
setCurrentToken(token);
|
||||||
|
setConfirmRegen(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async () => {
|
||||||
|
try {
|
||||||
|
await revokeToken(agent.slug);
|
||||||
|
setCurrentToken(null);
|
||||||
|
setConfirmRevoke(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToken = () => {
|
||||||
|
if (currentToken) {
|
||||||
|
navigator.clipboard.writeText(currentToken);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-full max-w-md">
|
<div
|
||||||
<h2 className="text-xl font-bold mb-4">Агент: {agent.name}</h2>
|
className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-lg"
|
||||||
<p className="text-[var(--muted)] text-sm mb-4">@{agent.slug}</p>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<p className="text-[var(--muted)] text-sm mb-4">Coming soon...</p>
|
>
|
||||||
<div className="flex gap-2">
|
<div className="p-5 border-b border-[var(--border)] flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold">{agent.name}</h2>
|
||||||
|
<div className="text-xs text-[var(--muted)]">@{agent.slug}</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
agent.status === "online"
|
||||||
|
? "bg-green-500/20 text-green-400"
|
||||||
|
: "bg-gray-500/20 text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{agent.status === "online" ? "online" : "offline"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--muted)] mb-1">Название</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--muted)] mb-1">Capabilities</label>
|
||||||
|
<input
|
||||||
|
value={capabilities}
|
||||||
|
onChange={(e) => setCapabilities(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
|
||||||
|
placeholder="code, review, deploy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--muted)] mb-1">Chat listen</label>
|
||||||
|
<select
|
||||||
|
value={chatListen}
|
||||||
|
onChange={(e) => setChatListen(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">all</option>
|
||||||
|
<option value="mentions">mentions</option>
|
||||||
|
<option value="none">none</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--muted)] mb-1">Task listen</label>
|
||||||
|
<select
|
||||||
|
value={taskListen}
|
||||||
|
onChange={(e) => setTaskListen(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">all</option>
|
||||||
|
<option value="assigned">assigned</option>
|
||||||
|
<option value="none">none</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--muted)] mb-1">Prompt</label>
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm resize-y min-h-[60px]"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Системный промпт агента..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--muted)] mb-1">Модель</label>
|
||||||
|
<input
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
|
||||||
|
placeholder="claude-sonnet-4-20250514, gpt-4o, ..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token section */}
|
||||||
|
<div className="pt-3 border-t border-[var(--border)]">
|
||||||
|
<div className="text-sm text-[var(--muted)] mb-2">Токен доступа</div>
|
||||||
|
{currentToken ? (
|
||||||
|
<div>
|
||||||
|
<div className="p-3 bg-[var(--bg)] border border-[var(--border)] rounded-lg font-mono text-xs break-all select-all mb-2">
|
||||||
|
{currentToken}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={copyToken}
|
||||||
|
className="px-3 py-1.5 bg-[var(--accent)] text-white rounded text-xs hover:opacity-90"
|
||||||
|
>
|
||||||
|
{copied ? "Скопировано!" : "Скопировать"}
|
||||||
|
</button>
|
||||||
|
{confirmRegen ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded text-xs hover:bg-yellow-500/30"
|
||||||
|
>
|
||||||
|
Да, новый
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfirmRegen(false)} className="text-xs text-[var(--muted)]">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmRegen(true)}
|
||||||
|
className="text-xs text-[var(--muted)] hover:text-[var(--fg)]"
|
||||||
|
>
|
||||||
|
Перегенерировать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{confirmRevoke ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleRevoke}
|
||||||
|
className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30"
|
||||||
|
>
|
||||||
|
Да, отозвать
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfirmRevoke(false)} className="text-xs text-[var(--muted)]">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmRevoke(true)}
|
||||||
|
className="text-xs text-red-400/60 hover:text-red-400"
|
||||||
|
>
|
||||||
|
Отозвать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-[var(--muted)]">Токен отозван</span>
|
||||||
|
<button
|
||||||
|
onClick={handleRegenerate}
|
||||||
|
className="text-xs text-[var(--accent)] hover:underline"
|
||||||
|
>
|
||||||
|
Сгенерировать новый
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 border-t border-[var(--border)] flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
|
className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
|
||||||
text-sm hover:bg-white/5 transition-colors"
|
|
||||||
>
|
>
|
||||||
Закрыть
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !name.trim()}
|
||||||
|
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "..." : "Сохранить"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,28 +1,27 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { isAuthenticated } from "@/lib/auth-client";
|
import { isAuthenticated } from "@/lib/auth-client";
|
||||||
import { wsClient } from "@/lib/ws";
|
import { wsClient } from "@/lib/ws";
|
||||||
|
|
||||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [checked, setChecked] = useState(false);
|
const [checked, setChecked] = useState(false);
|
||||||
const [isAuth, setIsAuth] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authStatus = isAuthenticated();
|
if (!isAuthenticated()) {
|
||||||
setIsAuth(authStatus);
|
navigate("/login");
|
||||||
|
} else {
|
||||||
setChecked(true);
|
setChecked(true);
|
||||||
|
|
||||||
if (authStatus) {
|
|
||||||
// Connect WebSocket after auth check
|
// Connect WebSocket after auth check
|
||||||
if (!wsClient.connected) {
|
if (!wsClient.connected) {
|
||||||
wsClient.connect();
|
wsClient.connect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Don't disconnect on unmount — singleton stays alive
|
// Don't disconnect on unmount — singleton stays alive
|
||||||
};
|
};
|
||||||
}, []);
|
}, [navigate]);
|
||||||
|
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
return (
|
return (
|
||||||
@ -32,9 +31,5 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuth) {
|
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
@ -1,16 +1,140 @@
|
|||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { Message } from "@/lib/api";
|
||||||
|
import { getMessages, sendMessage } from "@/lib/api";
|
||||||
|
import { wsClient } from "@/lib/ws";
|
||||||
|
|
||||||
|
const AUTHOR_ICON: Record<string, string> = {
|
||||||
|
human: "👤",
|
||||||
|
agent: "🤖",
|
||||||
|
system: "⚙️",
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
fullscreen?: boolean;
|
fullscreen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatPanel({ chatId, fullscreen: _fullscreen }: Props) {
|
export default function ChatPanel({ chatId, fullscreen = false }: Props) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chatId) {
|
||||||
|
getMessages({ chat_id: chatId, limit: 50 }).then(setMessages).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [chatId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = wsClient.on("message.new", (data: any) => {
|
||||||
|
if (data.chat_id === chatId) {
|
||||||
|
setMessages((prev) => [...prev, data as Message]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => { unsub?.(); };
|
||||||
|
}, [chatId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fullscreen) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-[var(--muted)]">
|
<div className="flex flex-col h-full">
|
||||||
<div className="text-center">
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
|
||||||
<h3 className="text-lg mb-2">💬 Chat Panel</h3>
|
{messages.map((msg) => (
|
||||||
<p className="text-sm">Chat ID: {chatId}</p>
|
<div key={msg.id} className="text-sm">
|
||||||
<p className="text-xs mt-1">Coming soon...</p>
|
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
|
||||||
|
<span>{AUTHOR_ICON[msg.author_type] || "👤"}</span>
|
||||||
|
<span className="font-medium">{msg.author_slug}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-5 whitespace-pre-wrap">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-sm text-[var(--muted)] italic py-8 text-center">Нет сообщений</div>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-3 border-t border-[var(--border)] flex gap-2">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||||
|
placeholder="Сообщение..."
|
||||||
|
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending}
|
||||||
|
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
↵
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact mode (collapsible)
|
||||||
|
return (
|
||||||
|
<div className={`border-b border-[var(--border)] transition-all ${open ? "h-64" : "h-10"}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="w-full h-10 px-4 flex items-center text-sm text-[var(--muted)] hover:text-[var(--fg)]"
|
||||||
|
>
|
||||||
|
💬 Чат {open ? "▼" : "▲"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="flex flex-col h-[calc(100%-2.5rem)]">
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-2 space-y-2">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div key={msg.id} className="text-sm">
|
||||||
|
<span className="text-xs text-[var(--muted)]">
|
||||||
|
{AUTHOR_ICON[msg.author_type] || "👤"} {msg.author_slug}
|
||||||
|
</span>
|
||||||
|
<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">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => 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)]"
|
||||||
|
/>
|
||||||
|
<button onClick={handleSend} disabled={sending} className="text-sm text-[var(--accent)] px-2 disabled:opacity-50">↵</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@ -1,19 +1,91 @@
|
|||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { MemberCreateResponse } from "@/lib/api";
|
||||||
|
import { createMember } from "@/lib/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onCreated: () => void;
|
onCreated: (member: MemberCreateResponse) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CreateAgentModal({ onCreated: _onCreated, onClose }: Props) {
|
export default function CreateAgentModal({ onCreated, onClose }: Props) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [capabilities, setCapabilities] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-zа-яё0-9\s-]/gi, "")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.slice(0, 50);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || !slug) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const caps = capabilities
|
||||||
|
.split(",")
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const member = await createMember({
|
||||||
|
name: name.trim(),
|
||||||
|
slug,
|
||||||
|
type: "agent",
|
||||||
|
agent_config: {
|
||||||
|
capabilities: caps,
|
||||||
|
chat_listen: "mentions",
|
||||||
|
task_listen: "assigned",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (member.token) {
|
||||||
|
setToken(member.token);
|
||||||
|
}
|
||||||
|
onCreated(member);
|
||||||
|
} catch {
|
||||||
|
setError("Ошибка создания агента");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToken = () => {
|
||||||
|
if (token) {
|
||||||
|
navigator.clipboard.writeText(token);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-full max-w-md">
|
<div
|
||||||
<h2 className="text-xl font-bold mb-4">Создать агента</h2>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<p className="text-[var(--muted)] text-sm mb-4">Coming soon...</p>
|
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-96"
|
||||||
<div className="flex gap-2">
|
>
|
||||||
|
<h2 className="text-lg font-bold mb-4">Агент создан</h2>
|
||||||
|
<div className="mb-3 p-2 bg-yellow-500/10 border border-yellow-500/30 rounded text-yellow-400 text-sm">
|
||||||
|
⚠️ Токен показывается только один раз. Сохраните его сейчас!
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 p-3 bg-[var(--bg)] border border-[var(--border)] rounded-lg font-mono text-xs break-all select-all">
|
||||||
|
{token}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={copyToken}
|
||||||
|
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90"
|
||||||
|
>
|
||||||
|
{copied ? "Скопировано!" : "Скопировать"}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
|
className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
|
||||||
text-sm hover:bg-white/5 transition-colors"
|
|
||||||
>
|
>
|
||||||
Закрыть
|
Закрыть
|
||||||
</button>
|
</button>
|
||||||
@ -21,4 +93,61 @@ export default function CreateAgentModal({ onCreated: _onCreated, onClose }: Pro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<form
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-96"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-bold mb-4">Новый агент</h2>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-3 p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="block text-sm text-[var(--muted)] mb-1">Название</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full mb-1 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg
|
||||||
|
outline-none focus:border-[var(--accent)] text-sm"
|
||||||
|
placeholder="Code Assistant"
|
||||||
|
/>
|
||||||
|
{slug && <div className="text-xs text-[var(--muted)] mb-3">Slug: {slug}</div>}
|
||||||
|
|
||||||
|
<label className="block text-sm text-[var(--muted)] mb-1">Capabilities</label>
|
||||||
|
<input
|
||||||
|
value={capabilities}
|
||||||
|
onChange={(e) => setCapabilities(e.target.value)}
|
||||||
|
className="w-full mb-4 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg
|
||||||
|
outline-none focus:border-[var(--accent)] text-sm"
|
||||||
|
placeholder="code, review, deploy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !name.trim()}
|
||||||
|
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm
|
||||||
|
hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "..." : "Создать"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createProject, type Project } from "@/lib/api";
|
import type { Project } from "@/lib/api";
|
||||||
|
import { createProject } from "@/lib/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onCreated: (project: Project) => void;
|
onCreated: (project: Project) => void;
|
||||||
@ -8,107 +10,90 @@ interface Props {
|
|||||||
|
|
||||||
export default function CreateProjectModal({ onCreated, onClose }: Props) {
|
export default function CreateProjectModal({ onCreated, onClose }: Props) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [slug, setSlug] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-zа-яё0-9\s-]/gi, "")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.slice(0, 50);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
if (!name.trim() || !slug) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
try {
|
try {
|
||||||
const project = await createProject({ name, slug, description: description || undefined });
|
const project = await createProject({
|
||||||
|
name: name.trim(),
|
||||||
|
slug,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
});
|
||||||
onCreated(project);
|
onCreated(project);
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
setError(err.message || "Ошибка создания проекта");
|
setError("Ошибка создания проекта");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateSlug = () => {
|
|
||||||
if (name && !slug) {
|
|
||||||
const generated = name.toLowerCase()
|
|
||||||
.replace(/[^a-zа-я0-9\s]/g, '')
|
|
||||||
.replace(/\s+/g, '-');
|
|
||||||
setSlug(generated);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-full max-w-md">
|
<form
|
||||||
<h2 className="text-xl font-bold mb-4">Создать проект</h2>
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-96"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-bold mb-4">Новый проект</h2>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
|
<div className="mb-3 p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<label className="block text-sm text-[var(--muted)] mb-1">Название</label>
|
||||||
<div>
|
|
||||||
<label className="block text-sm mb-1">Название</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
autoFocus
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
onBlur={generateSlug}
|
className="w-full mb-1 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg
|
||||||
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
|
|
||||||
outline-none focus:border-[var(--accent)] text-sm"
|
outline-none focus:border-[var(--accent)] text-sm"
|
||||||
placeholder="Мой проект"
|
placeholder="Мой проект"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
{slug && <div className="text-xs text-[var(--muted)] mb-3">Slug: {slug}</div>}
|
||||||
|
|
||||||
<div>
|
<label className="block text-sm text-[var(--muted)] mb-1">Описание</label>
|
||||||
<label className="block text-sm mb-1">Slug (URL)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={slug}
|
|
||||||
onChange={(e) => setSlug(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
|
|
||||||
outline-none focus:border-[var(--accent)] text-sm font-mono"
|
|
||||||
placeholder="my-project"
|
|
||||||
pattern="[a-z0-9-]+"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm mb-1">Описание (опционально)</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
|
className="w-full mb-4 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg
|
||||||
outline-none focus:border-[var(--accent)] text-sm resize-none"
|
outline-none focus:border-[var(--accent)] text-sm resize-none"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Краткое описание проекта"
|
placeholder="Необязательно"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
|
className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
|
||||||
text-sm hover:bg-white/5 transition-colors"
|
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !name || !slug}
|
disabled={loading || !name.trim()}
|
||||||
className="flex-1 py-2 bg-[var(--accent)] text-white rounded text-sm
|
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm
|
||||||
hover:opacity-90 transition-opacity disabled:opacity-50"
|
hover:opacity-90 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? "Создаём..." : "Создать"}
|
{loading ? "..." : "Создать"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
112
src/components/CreateTaskModal.tsx
Normal file
112
src/components/CreateTaskModal.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { Task } from "@/lib/api";
|
||||||
|
import { createTask } 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,16 +1,226 @@
|
|||||||
|
|
||||||
|
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 {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KanbanBoard({ projectId: _projectId, projectSlug }: Props) {
|
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: any) => {
|
||||||
|
if (data.project_id === projectId) {
|
||||||
|
setTasks((prev) => [...prev, data as Task]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribeUpdated = wsClient.on("task.updated", (data: any) => {
|
||||||
|
if (data.project_id === projectId) {
|
||||||
|
setTasks((prev) => prev.map((t) => t.id === data.id ? { ...t, ...data } : t));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribeAssigned = wsClient.on("task.assigned", (data: any) => {
|
||||||
|
if (data.project_id === projectId) {
|
||||||
|
setTasks((prev) => prev.map((t) => t.id === data.id ? { ...t, assignee_slug: data.assignee_slug, assigned_at: data.assigned_at } : 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)]">{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="flex items-center justify-center h-full text-[var(--muted)]">
|
<div className="h-full flex flex-col">
|
||||||
<div className="text-center">
|
{/* Mobile column tabs */}
|
||||||
<h3 className="text-lg mb-2">📋 Kanban Board</h3>
|
<div className="md:hidden flex overflow-x-auto border-b border-[var(--border)] px-2 py-1 gap-1 shrink-0">
|
||||||
<p className="text-sm">Project: {projectSlug}</p>
|
{COLUMNS.map((col) => {
|
||||||
<p className="text-xs mt-1">Coming soon...</p>
|
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>
|
</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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create task modal */}
|
||||||
|
{createInStatus && (
|
||||||
|
<CreateTaskModal
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
initialStatus={createInStatus}
|
||||||
|
onClose={() => setCreateInStatus(null)}
|
||||||
|
onCreated={(task) => setTasks((prev) => [...prev, task])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@ -1,17 +1,16 @@
|
|||||||
|
|
||||||
import type { Project } from "@/lib/api";
|
import type { Project } from "@/lib/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectFiles({ project }: Props) {
|
export default function ProjectFiles({ project: _project }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-[var(--muted)]">
|
<div className="flex flex-col items-center justify-center h-full text-[var(--muted)]">
|
||||||
<div className="text-center">
|
<div className="text-4xl mb-4">📁</div>
|
||||||
<h3 className="text-lg mb-2">📁 Project Files</h3>
|
<div className="text-sm">Файлы проекта</div>
|
||||||
<p className="text-sm">Project: {project.name}</p>
|
<div className="text-xs mt-1">Скоро здесь можно будет загружать и просматривать файлы</div>
|
||||||
<p className="text-xs mt-1">Coming soon...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,17 +1,138 @@
|
|||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import type { Project } from "@/lib/api";
|
import type { Project } from "@/lib/api";
|
||||||
|
import { updateProject, deleteProject } from "@/lib/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
onUpdated: (project: Project) => void;
|
onUpdated: (p: Project) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectSettings({ project, onUpdated: _onUpdated }: Props) {
|
export default function ProjectSettings({ project, onUpdated }: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [name, setName] = useState(project.name);
|
||||||
|
const [description, setDescription] = useState(project.description || "");
|
||||||
|
const [repoUrls, setRepoUrls] = useState(project.repo_urls?.join("\n") || "");
|
||||||
|
const [status, setStatus] = useState(project.status);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const urls = repoUrls.split("\n").map((u) => u.trim()).filter(Boolean);
|
||||||
|
const updated = await updateProject(project.slug, {
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
|
repo_urls: urls,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
onUpdated(updated);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteProject(project.slug);
|
||||||
|
navigate("/");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-[var(--muted)]">
|
<div className="max-w-xl mx-auto p-6 space-y-6">
|
||||||
<div className="text-center">
|
<h2 className="text-lg font-bold">Настройки проекта</h2>
|
||||||
<h3 className="text-lg mb-2">⚙️ Project Settings</h3>
|
|
||||||
<p className="text-sm">Project: {project.name}</p>
|
<div>
|
||||||
<p className="text-xs mt-1">Coming soon...</p>
|
<label className="block text-sm text-[var(--muted)] mb-1">Название</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--muted)] mb-1">Описание</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm resize-y"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Описание проекта..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--muted)] mb-1">Репозитории (по одному на строку)</label>
|
||||||
|
<textarea
|
||||||
|
value={repoUrls}
|
||||||
|
onChange={(e) => setRepoUrls(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm font-mono resize-y"
|
||||||
|
rows={3}
|
||||||
|
placeholder="https://github.com/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--muted)] mb-1">Статус</label>
|
||||||
|
<select
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
|
||||||
|
>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
<option value="paused">Paused</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !name.trim()}
|
||||||
|
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? "..." : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
{saved && <span className="text-sm text-green-400">Сохранено ✓</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-[var(--border)]">
|
||||||
|
<div className="text-sm text-[var(--muted)] mb-2">Зона опасности</div>
|
||||||
|
{confirmDelete ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-red-400">Проект и все задачи будут удалены!</span>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30"
|
||||||
|
>
|
||||||
|
Да, удалить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
className="px-3 py-1.5 text-xs text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
className="text-xs text-red-400/60 hover:text-red-400"
|
||||||
|
>
|
||||||
|
Удалить проект
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import type { Project } from "@/lib/api";
|
import type { Project } from "@/lib/api";
|
||||||
|
|||||||
360
src/components/TaskModal.tsx
Normal file
360
src/components/TaskModal.tsx
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { Task, Member, Step, Message } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
updateTask, deleteTask, getMembers,
|
||||||
|
getSteps, createStep, updateStep, deleteStep as _deleteStepApi,
|
||||||
|
getMessages, sendMessage,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
const STATUSES = [
|
||||||
|
{ 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 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" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUTHOR_ICON: Record<string, string> = {
|
||||||
|
human: "👤",
|
||||||
|
agent: "🤖",
|
||||||
|
system: "⚙️",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
task: Task;
|
||||||
|
projectId: string;
|
||||||
|
projectSlug: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdated: (task: Task) => void;
|
||||||
|
onDeleted: (taskId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskModal({ task, projectId: _projectId, projectSlug: _projectSlug, onClose, onUpdated, onDeleted }: Props) {
|
||||||
|
const [title, setTitle] = useState(task.title);
|
||||||
|
const [description, setDescription] = useState(task.description || "");
|
||||||
|
const [status, setStatus] = useState(task.status);
|
||||||
|
const [priority, setPriority] = useState(task.priority);
|
||||||
|
const [assigneeSlug, setAssigneeSlug] = useState(task.assignee_slug || "");
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [steps, setSteps] = useState<Step[]>(task.steps || []);
|
||||||
|
const [comments, setComments] = useState<Message[]>([]);
|
||||||
|
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(() => {
|
||||||
|
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<Task>) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await updateTask(task.id, patch);
|
||||||
|
onUpdated(updated);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteTask(task.id);
|
||||||
|
onDeleted(task.id);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 flex items-start justify-center z-50 overflow-y-auto py-8 px-4"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-2xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 md:p-6 border-b border-[var(--border)]">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
{editingTitle ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
setEditingTitle(false);
|
||||||
|
if (title.trim() && title !== task.title) save({ title: title.trim() });
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h2
|
||||||
|
className="text-xl font-bold cursor-pointer hover:text-[var(--accent)] transition-colors"
|
||||||
|
onClick={() => setEditingTitle(true)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-[var(--muted)] hover:text-[var(--fg)] transition-colors text-xl leading-none cursor-pointer shrink-0 mt-1"
|
||||||
|
title="Закрыть"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--muted)]">{task.key}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row">
|
||||||
|
{/* Left: Description + Steps + Comments */}
|
||||||
|
<div className="flex-1 p-4 md:p-6 border-b md:border-b-0 md:border-r border-[var(--border)] space-y-6">
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-[var(--muted)] mb-2 flex items-center justify-between">
|
||||||
|
<span>Описание</span>
|
||||||
|
{!editingDesc && (
|
||||||
|
<button onClick={() => setEditingDesc(true)} className="text-xs text-[var(--accent)] hover:underline">
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingDesc ? (
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
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 min-h-[120px]"
|
||||||
|
rows={6}
|
||||||
|
placeholder="Описание задачи (Markdown)..."
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingDesc(false); save({ description: description.trim() || undefined }); }}
|
||||||
|
className="px-3 py-1 bg-[var(--accent)] text-white rounded text-xs"
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setDescription(task.description || ""); setEditingDesc(false); }}
|
||||||
|
className="px-3 py-1 text-[var(--muted)] text-xs"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : description ? (
|
||||||
|
<div className="text-sm whitespace-pre-wrap">{description}</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-[var(--muted)] italic">Нет описания</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-[var(--muted)] mb-2">
|
||||||
|
Этапы {steps.length > 0 && `(${steps.filter((s) => s.done).length}/${steps.length})`}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<label key={step.id} className="flex items-center gap-2 text-sm cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={step.done}
|
||||||
|
onChange={() => handleToggleStep(step)}
|
||||||
|
className="accent-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<span className={step.done ? "line-through text-[var(--muted)]" : ""}>{step.title}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<input
|
||||||
|
value={newStep}
|
||||||
|
onChange={(e) => setNewStep(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleAddStep()}
|
||||||
|
placeholder="Новый этап..."
|
||||||
|
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1 text-xs outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<button onClick={handleAddStep} className="text-xs text-[var(--accent)]">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-[var(--muted)] mb-2">Комментарии</div>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleAddComment()}
|
||||||
|
placeholder="Написать комментарий..."
|
||||||
|
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<button onClick={handleAddComment} className="text-xs text-[var(--accent)] px-2">↵</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 max-h-[300px] overflow-y-auto">
|
||||||
|
{[...comments].reverse().map((msg) => (
|
||||||
|
<div key={msg.id} className="text-sm">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
|
||||||
|
<span>{AUTHOR_ICON[msg.author_type] || "👤"}</span>
|
||||||
|
<span className="font-medium">{msg.author_slug}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-5 whitespace-pre-wrap">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{comments.length === 0 && (
|
||||||
|
<div className="text-sm text-[var(--muted)] italic">Нет комментариев</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Properties */}
|
||||||
|
<div className="w-full md:w-64 shrink-0 p-4 md:p-6 space-y-4">
|
||||||
|
{/* Status */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--muted)] mb-1">Статус</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
onClick={() => { setStatus(s.key); save({ status: s.key }); }}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors
|
||||||
|
${status === s.key ? "bg-white/10 text-[var(--fg)]" : "text-[var(--muted)] hover:bg-white/5"}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ background: s.color }} />
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--muted)] mb-1">Приоритет</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{PRIORITIES.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
onClick={() => { setPriority(p.key); save({ priority: 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>
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--muted)] mb-1">Исполнитель</div>
|
||||||
|
<select
|
||||||
|
value={assigneeSlug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAssigneeSlug(e.target.value);
|
||||||
|
save({ assignee_slug: e.target.value || undefined } as any);
|
||||||
|
}}
|
||||||
|
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-1.5 text-sm outline-none focus:border-[var(--accent)]"
|
||||||
|
>
|
||||||
|
<option value="">Не назначен</option>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.slug} value={m.slug}>
|
||||||
|
{m.type === "agent" ? "🤖 " : "👤 "}{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{task.labels && task.labels.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[var(--muted)] mb-1">Лейблы</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{task.labels.map((l) => (
|
||||||
|
<span key={l} className="text-xs px-2 py-0.5 rounded bg-white/10">{l}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<div className="pt-4 border-t border-[var(--border)]">
|
||||||
|
{confirmDelete ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleDelete} className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30">
|
||||||
|
Да, удалить
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfirmDelete(false)} className="px-3 py-1.5 text-[var(--muted)] text-xs">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setConfirmDelete(true)} className="text-xs text-red-400/60 hover:text-red-400">
|
||||||
|
Удалить задачу
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user