feat: allow slug change for members via API + UI
This commit is contained in:
parent
47811ceb91
commit
469a8a9cfc
@ -877,3 +877,7 @@ Swagger UI документация
|
|||||||
**CORS:** автоматически обрабатывается для разрешённых origins
|
**CORS:** автоматически обрабатывается для разрешённых origins
|
||||||
|
|
||||||
Этот документ описывает API на основе исходного кода в `/root/projects/team-board/tracker/src/tracker/api/` на дату создания спецификации.
|
Этот документ описывает API на основе исходного кода в `/root/projects/team-board/tracker/src/tracker/api/` на дату создания спецификации.
|
||||||
|
### Обновление slug (добавлено 2026-03-13)
|
||||||
|
`PATCH /api/v1/members/{member_id}` теперь принимает поле `slug`.
|
||||||
|
- Валидация: slug уникален, при конфликте → 409
|
||||||
|
- Формат: lowercase, только `[a-z0-9_-]`
|
||||||
|
|||||||
2
tracker
2
tracker
@ -1 +1 @@
|
|||||||
Subproject commit e39c26d3212a7b656bd5629c6feaafcb259f71e3
|
Subproject commit f7622e13d5da79020848b86cc13d4e21f931ad65
|
||||||
@ -6,6 +6,7 @@ import ProjectPage from "@/pages/ProjectPage";
|
|||||||
import CreateProjectPage from "@/pages/CreateProjectPage";
|
import CreateProjectPage from "@/pages/CreateProjectPage";
|
||||||
import SettingsLayout from "@/pages/settings/SettingsLayout";
|
import SettingsLayout from "@/pages/settings/SettingsLayout";
|
||||||
import SettingsPage from "@/pages/settings/SettingsPage";
|
import SettingsPage from "@/pages/settings/SettingsPage";
|
||||||
|
import LabelsPage from "@/pages/settings/LabelsPage";
|
||||||
import AgentsPage from "@/pages/settings/AgentsPage";
|
import AgentsPage from "@/pages/settings/AgentsPage";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -20,7 +21,7 @@ function App() {
|
|||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
<Route path="/projects/:slug" element={
|
<Route path="/projects/:id" element={
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<ProjectPage />
|
<ProjectPage />
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
@ -38,6 +39,7 @@ function App() {
|
|||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
}>
|
}>
|
||||||
<Route index element={<SettingsPage />} />
|
<Route index element={<SettingsPage />} />
|
||||||
|
<Route path="labels" element={<LabelsPage />} />
|
||||||
<Route path="agents" element={<AgentsPage />} />
|
<Route path="agents" element={<AgentsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -1,25 +1,312 @@
|
|||||||
import type { Member } from "@/lib/api";
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { Member, Label } from "@/lib/api";
|
||||||
|
import { updateMember, regenerateToken, revokeToken, getLabels } 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 [slug, setSlug] = useState(agent.slug);
|
||||||
|
const [slugError, setSlugError] = useState("");
|
||||||
|
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 [allLabels, setAllLabels] = useState<Label[]>([]);
|
||||||
|
const [agentLabels, setAgentLabels] = useState<string[]>(agent.agent_config?.labels || []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getLabels().then(setAllLabels).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const caps = capabilities.split(",").map((c) => c.trim()).filter(Boolean);
|
||||||
|
const trimmedSlug = slug.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
||||||
|
if (!trimmedSlug) {
|
||||||
|
setSlugError("Slug не может быть пустым");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = await updateMember(agent.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
slug: trimmedSlug !== agent.slug ? trimmedSlug : undefined,
|
||||||
|
agent_config: {
|
||||||
|
capabilities: caps,
|
||||||
|
labels: agentLabels,
|
||||||
|
chat_listen: chatListen,
|
||||||
|
task_listen: taskListen,
|
||||||
|
prompt: prompt.trim() || null,
|
||||||
|
model: model.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onUpdated(updated);
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.response?.status === 409) {
|
||||||
|
setSlugError("Этот slug уже занят");
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerate = async () => {
|
||||||
|
try {
|
||||||
|
const { token } = await regenerateToken(agent.id);
|
||||||
|
setCurrentToken(token);
|
||||||
|
setConfirmRegen(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = async () => {
|
||||||
|
try {
|
||||||
|
await revokeToken(agent.id);
|
||||||
|
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">Slug</label>
|
||||||
|
<input
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => { setSlug(e.target.value); setSlugError(""); }}
|
||||||
|
className={`w-full px-3 py-2 bg-[var(--bg)] border rounded-lg outline-none text-sm ${slugError ? 'border-red-500' : 'border-[var(--border)] focus:border-[var(--accent)]'}`}
|
||||||
|
placeholder="agent-slug"
|
||||||
|
/>
|
||||||
|
{slugError && <div className="text-xs text-red-400 mt-1">{slugError}</div>}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{allLabels.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--muted)] mb-1">Лейблы</label>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{allLabels.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l.id}
|
||||||
|
onClick={() => {
|
||||||
|
setAgentLabels(prev =>
|
||||||
|
prev.includes(l.name) ? prev.filter(n => n !== l.name) : [...prev, l.name]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors border ${
|
||||||
|
agentLabels.includes(l.name)
|
||||||
|
? "border-[var(--accent)] bg-[var(--accent)]/20"
|
||||||
|
: "border-[var(--border)] bg-white/5 text-[var(--muted)] hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: l.color }} />
|
||||||
|
{l.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-[var(--muted)] mt-1">Для авто-назначения: лейблы задачи ∩ лейблы агента</p>
|
||||||
|
</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,40 +1,35 @@
|
|||||||
|
|
||||||
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 (
|
||||||
<div className="flex h-screen items-center justify-center text-[var(--muted)]">
|
<div className="flex h-[100dvh] items-center justify-center text-[var(--muted)]">
|
||||||
Загрузка...
|
Загрузка...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuth) {
|
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
@ -1,15 +1,443 @@
|
|||||||
interface Props {
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
chatId: string;
|
import type { Message, UploadedFile } from "@/lib/api";
|
||||||
fullscreen?: boolean;
|
import { getMessages, sendMessage, uploadFile, getAttachmentUrl } from "@/lib/api";
|
||||||
|
import { wsClient } from "@/lib/ws";
|
||||||
|
import MentionInput from "./MentionInput";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
const AUTHOR_ICON: Record<string, string> = {
|
||||||
|
human: "👤",
|
||||||
|
agent: "🤖",
|
||||||
|
system: "⚙️",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Streaming state for agent that is currently generating */
|
||||||
|
interface StreamingState {
|
||||||
|
agentSlug: string;
|
||||||
|
text: string;
|
||||||
|
thinking: string;
|
||||||
|
tools: Array<{ tool: string; status: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatPanel({ chatId, fullscreen }: Props) {
|
interface Props {
|
||||||
|
chatId: string;
|
||||||
|
projectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageMime(mime: string | null): boolean {
|
||||||
|
return !!mime && mime.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatPanel({ chatId, projectId }: Props) {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const mentionMapRef = useRef<Map<string, string>>(new Map()); // slug → member_id
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [pendingFiles, setPendingFiles] = useState<UploadedFile[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [streaming, setStreaming] = useState<StreamingState | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const isInitial = useRef(true);
|
||||||
|
|
||||||
|
// Load initial messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatId) return;
|
||||||
|
isInitial.current = true;
|
||||||
|
setHasMore(true);
|
||||||
|
getMessages({ chat_id: chatId, limit: PAGE_SIZE })
|
||||||
|
.then((msgs) => {
|
||||||
|
setMessages(msgs);
|
||||||
|
setHasMore(msgs.length >= PAGE_SIZE);
|
||||||
|
setTimeout(() => bottomRef.current?.scrollIntoView(), 50);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [chatId]);
|
||||||
|
|
||||||
|
// WS: new messages
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = wsClient.on("message.new", (msg: Message) => {
|
||||||
|
if (msg.chat_id !== chatId) return;
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
|
return [...prev, msg];
|
||||||
|
});
|
||||||
|
// Clear streaming when final message arrives
|
||||||
|
setStreaming(null);
|
||||||
|
});
|
||||||
|
return () => { unsub?.(); };
|
||||||
|
}, [chatId]);
|
||||||
|
|
||||||
|
// WS: agent streaming events
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubStart = wsClient.on("agent.stream.start", (data: any) => {
|
||||||
|
if (data.chat_id !== chatId) return;
|
||||||
|
setStreaming({ agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] });
|
||||||
|
});
|
||||||
|
const unsubDelta = wsClient.on("agent.stream.delta", (data: any) => {
|
||||||
|
if (data.chat_id !== chatId) return;
|
||||||
|
setStreaming((prev) => {
|
||||||
|
if (!prev) return { agentSlug: data.agent_slug || "agent", text: "", thinking: "", tools: [] };
|
||||||
|
if (data.block_type === "thinking") {
|
||||||
|
return { ...prev, thinking: prev.thinking + (data.text || "") };
|
||||||
|
}
|
||||||
|
return { ...prev, text: prev.text + (data.text || "") };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const unsubTool = wsClient.on("agent.stream.tool", (data: any) => {
|
||||||
|
if (data.chat_id !== chatId) return;
|
||||||
|
setStreaming((prev) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
const tools = [...prev.tools];
|
||||||
|
const existing = tools.findIndex((t) => t.tool === data.tool);
|
||||||
|
if (existing >= 0) {
|
||||||
|
tools[existing] = { tool: data.tool, status: data.status };
|
||||||
|
} else {
|
||||||
|
tools.push({ tool: data.tool, status: data.status });
|
||||||
|
}
|
||||||
|
return { ...prev, tools };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const unsubEnd = wsClient.on("agent.stream.end", (data: any) => {
|
||||||
|
if (data.chat_id !== chatId) return;
|
||||||
|
// Don't clear immediately — wait for message.new with final content
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsubStart?.();
|
||||||
|
unsubDelta?.();
|
||||||
|
unsubTool?.();
|
||||||
|
unsubEnd?.();
|
||||||
|
};
|
||||||
|
}, [chatId]);
|
||||||
|
|
||||||
|
// Auto-scroll on new messages (only if near bottom)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitial.current) {
|
||||||
|
isInitial.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (el.scrollHeight - el.scrollTop - el.clientHeight < 100) {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Load older messages
|
||||||
|
const loadOlder = useCallback(async () => {
|
||||||
|
if (loadingOlder || !hasMore || !chatId || messages.length === 0) return;
|
||||||
|
setLoadingOlder(true);
|
||||||
|
const el = scrollRef.current;
|
||||||
|
const prevHeight = el?.scrollHeight || 0;
|
||||||
|
try {
|
||||||
|
const older = await getMessages({ chat_id: chatId, limit: PAGE_SIZE, offset: messages.length });
|
||||||
|
if (older.length < PAGE_SIZE) setHasMore(false);
|
||||||
|
if (older.length > 0) {
|
||||||
|
setMessages((prev) => {
|
||||||
|
const ids = new Set(prev.map((m) => m.id));
|
||||||
|
return [...older.filter((m) => !ids.has(m.id)), ...prev];
|
||||||
|
});
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (el) el.scrollTop = el.scrollHeight - prevHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load older messages:", e);
|
||||||
|
} finally {
|
||||||
|
setLoadingOlder(false);
|
||||||
|
}
|
||||||
|
}, [chatId, messages.length, loadingOlder, hasMore]);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el && el.scrollTop < 50 && hasMore && !loadingOlder) loadOlder();
|
||||||
|
}, [loadOlder, hasMore, loadingOlder]);
|
||||||
|
|
||||||
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files?.length) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const uploaded: UploadedFile[] = [];
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const result = await uploadFile(file);
|
||||||
|
uploaded.push(result);
|
||||||
|
}
|
||||||
|
setPendingFiles((prev) => [...prev, ...uploaded]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Upload failed:", err);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePendingFile = (fileId: string) => {
|
||||||
|
setPendingFiles((prev) => prev.filter((f) => f.file_id !== fileId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if ((!input.trim() && pendingFiles.length === 0) || !chatId || sending) return;
|
||||||
|
const text = input.trim() || (pendingFiles.length > 0 ? `📎 ${pendingFiles.map(f => f.filename).join(", ")}` : "");
|
||||||
|
const attachments = pendingFiles.map((f) => ({
|
||||||
|
file_id: f.file_id,
|
||||||
|
filename: f.filename,
|
||||||
|
mime_type: f.mime_type,
|
||||||
|
size: f.size,
|
||||||
|
storage_name: f.storage_name,
|
||||||
|
}));
|
||||||
|
setInput("");
|
||||||
|
setPendingFiles([]);
|
||||||
|
mentionMapRef.current.clear();
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
// Extract @mentions from text → resolve to member IDs
|
||||||
|
const mentionMatches = text.match(/@(\w+)/g);
|
||||||
|
const mentionSlugs = mentionMatches ? [...new Set(mentionMatches.map(m => m.slice(1)))] : [];
|
||||||
|
const mentions = mentionSlugs
|
||||||
|
.map(slug => mentionMapRef.current.get(slug))
|
||||||
|
.filter((id): id is string => !!id);
|
||||||
|
|
||||||
|
const msg = await sendMessage({
|
||||||
|
chat_id: chatId,
|
||||||
|
content: text,
|
||||||
|
mentions: mentions.length > 0 ? mentions : undefined,
|
||||||
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
|
});
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
|
return [...prev, msg];
|
||||||
|
});
|
||||||
|
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: "smooth" }), 50);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to send:", e);
|
||||||
|
setInput(text);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = (text: string) => {
|
||||||
|
const parts = text.split(/(@\w+)/g);
|
||||||
|
return parts.map((part, i) =>
|
||||||
|
/^@\w+$/.test(part) ? (
|
||||||
|
<span key={i} className="text-[var(--accent)] font-medium">{part}</span>
|
||||||
|
) : (
|
||||||
|
<span key={i}>{part}</span>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAttachments = (attachments: Message["attachments"]) => {
|
||||||
|
if (!attachments?.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-[var(--muted)]">
|
<div className="ml-5 mt-1 flex flex-wrap gap-2">
|
||||||
<div className="text-center">
|
{attachments.map((att) => {
|
||||||
<h3 className="text-lg mb-2">💬 Chat Panel</h3>
|
const url = getAttachmentUrl(att.id);
|
||||||
<p className="text-sm">Chat ID: {chatId}</p>
|
if (isImageMime(att.mime_type)) {
|
||||||
<p className="text-xs mt-1">Coming soon...</p>
|
return (
|
||||||
|
<a key={att.id} href={url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={att.filename}
|
||||||
|
className="max-w-[300px] max-h-[200px] rounded border border-[var(--border)] object-cover"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={att.id}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded border border-[var(--border)] bg-[var(--bg)] hover:bg-white/5 text-sm"
|
||||||
|
>
|
||||||
|
<span>📎</span>
|
||||||
|
<span className="truncate max-w-[200px]">{att.filename}</span>
|
||||||
|
<span className="text-[var(--muted)] text-xs">{formatFileSize(att.size)}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMessage = (msg: Message) => {
|
||||||
|
const bgStyle =
|
||||||
|
msg.author_type === "system"
|
||||||
|
? { backgroundColor: "rgba(161, 120, 0, 0.2)", borderRadius: 6, padding: "4px 8px" }
|
||||||
|
: msg.author_type === "agent"
|
||||||
|
? { backgroundColor: "rgba(0, 120, 60, 0.2)", borderRadius: 6, padding: "4px 8px" }
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="text-sm" style={bgStyle}>
|
||||||
|
<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_type === "system"
|
||||||
|
? (msg.actor ? `Система (${msg.actor.name})` : "Система")
|
||||||
|
: (msg.author?.name || msg.author?.slug || "Unknown")}
|
||||||
|
</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||||
|
</div>
|
||||||
|
{msg.thinking && (
|
||||||
|
<details className="ml-5 mb-1">
|
||||||
|
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
|
||||||
|
💭 Размышления
|
||||||
|
</summary>
|
||||||
|
<div className="mt-1 text-xs text-[var(--muted)] whitespace-pre-wrap bg-white/5 rounded p-2 max-h-[200px] overflow-y-auto">
|
||||||
|
{msg.thinking}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{msg.tool_log && msg.tool_log.length > 0 && (
|
||||||
|
<details className="ml-5 mb-1">
|
||||||
|
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
|
||||||
|
🔧 Инструменты ({msg.tool_log.length})
|
||||||
|
</summary>
|
||||||
|
<div className="mt-1 text-xs text-[var(--muted)] bg-white/5 rounded p-2 max-h-[300px] overflow-y-auto space-y-1">
|
||||||
|
{msg.tool_log.map((t, i) => (
|
||||||
|
<div key={i} className="border-b border-white/10 pb-1 last:border-0">
|
||||||
|
<span className="font-mono text-blue-400">{t.name}</span>
|
||||||
|
{t.result && (
|
||||||
|
<div className="ml-3 text-[var(--muted)] truncate max-w-[500px]" title={t.result}>
|
||||||
|
→ {t.result.slice(0, 150)}{t.result.length > 150 ? '…' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<div className="ml-5 whitespace-pre-wrap">{renderContent(msg.content)}</div>
|
||||||
|
{renderAttachments(msg.attachments)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="flex-1 overflow-y-auto px-6 py-4 space-y-3"
|
||||||
|
>
|
||||||
|
{loadingOlder && (
|
||||||
|
<div className="text-center text-xs text-[var(--muted)] py-2">Загрузка...</div>
|
||||||
|
)}
|
||||||
|
{!hasMore && messages.length > 0 && (
|
||||||
|
<div className="text-center text-xs text-[var(--muted)] py-2">— начало истории —</div>
|
||||||
|
)}
|
||||||
|
{messages.map(renderMessage)}
|
||||||
|
{messages.length === 0 && !streaming && (
|
||||||
|
<div className="text-sm text-[var(--muted)] italic py-8 text-center">Нет сообщений</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Streaming indicator */}
|
||||||
|
{streaming && (
|
||||||
|
<div className="text-sm" style={{ backgroundColor: "rgba(0, 120, 60, 0.2)", borderRadius: 6, padding: "4px 8px" }}>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-[var(--muted)] mb-0.5">
|
||||||
|
<span>🤖</span>
|
||||||
|
<span className="font-medium">{streaming.agentSlug}</span>
|
||||||
|
<span className="animate-pulse">●</span>
|
||||||
|
</div>
|
||||||
|
{streaming.thinking && (
|
||||||
|
<details className="ml-5 mb-1" open>
|
||||||
|
<summary className="text-xs text-[var(--muted)] cursor-pointer hover:text-[var(--fg)]">
|
||||||
|
💭 Размышления
|
||||||
|
</summary>
|
||||||
|
<div className="mt-1 text-xs text-[var(--muted)] whitespace-pre-wrap bg-white/5 rounded p-2 max-h-[200px] overflow-y-auto">
|
||||||
|
{streaming.thinking}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{streaming.tools.length > 0 && (
|
||||||
|
<div className="ml-5 mb-1 text-xs text-[var(--muted)]">
|
||||||
|
{streaming.tools.map((t, i) => (
|
||||||
|
<span key={i} className="mr-2">
|
||||||
|
{t.status === "running" ? "🔧" : "✅"} {t.tool}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{streaming.text && (
|
||||||
|
<div className="ml-5 whitespace-pre-wrap">{streaming.text}</div>
|
||||||
|
)}
|
||||||
|
{!streaming.text && streaming.tools.length === 0 && !streaming.thinking && (
|
||||||
|
<div className="ml-5 text-[var(--muted)] italic">Думает...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending files preview */}
|
||||||
|
{pendingFiles.length > 0 && (
|
||||||
|
<div className="px-6 py-2 border-t border-[var(--border)] flex flex-wrap gap-2">
|
||||||
|
{pendingFiles.map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.file_id}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded bg-[var(--accent)]/10 text-xs"
|
||||||
|
>
|
||||||
|
<span>📎</span>
|
||||||
|
<span className="truncate max-w-[150px]">{f.filename}</span>
|
||||||
|
<span className="text-[var(--muted)]">{formatFileSize(f.size)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removePendingFile(f.file_id)}
|
||||||
|
className="ml-1 text-[var(--muted)] hover:text-red-400"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="px-6 py-3 border-t border-[var(--border)] flex gap-2 items-end">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="px-2 py-2 text-[var(--muted)] hover:text-[var(--fg)] transition-colors shrink-0 disabled:opacity-50"
|
||||||
|
title="Прикрепить файл"
|
||||||
|
>
|
||||||
|
{uploading ? "⏳" : "📎"}
|
||||||
|
</button>
|
||||||
|
<MentionInput
|
||||||
|
projectId={projectId || ""}
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
onSubmit={handleSend}
|
||||||
|
onMentionInsert={(id, slug) => mentionMapRef.current.set(slug, id)}
|
||||||
|
placeholder="Сообщение... (@mention)"
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
<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 shrink-0"
|
||||||
|
>
|
||||||
|
↵
|
||||||
|
</button>
|
||||||
</div>
|
</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, 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, onClose }: Props) {
|
|||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
244
web-client-vite/src/components/CreateTaskModal.tsx
Normal file
244
web-client-vite/src/components/CreateTaskModal.tsx
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { Task, Member, Label } from "@/lib/api";
|
||||||
|
import { createTask, getMembers, getLabels } 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 TYPES = [
|
||||||
|
{ key: "task", label: "Задача" },
|
||||||
|
{ key: "bug", label: "Баг" },
|
||||||
|
{ key: "feature", label: "Фича" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
initialStatus: string;
|
||||||
|
parentId?: string;
|
||||||
|
parentKey?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (task: Task) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateTaskModal({ projectId, initialStatus, parentId, parentKey, onClose, onCreated }: Props) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [status, setStatus] = useState(initialStatus || "backlog");
|
||||||
|
const [priority, setPriority] = useState("medium");
|
||||||
|
const [type, setType] = useState("task");
|
||||||
|
const [assigneeId, setAssigneeId] = useState("");
|
||||||
|
const [reviewerId, setReviewerId] = useState("");
|
||||||
|
const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [labels, setLabels] = useState<Label[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getMembers().then(setMembers).catch(() => {});
|
||||||
|
getLabels().then(setLabels).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const task = await createTask(projectId, {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
type,
|
||||||
|
parent_id: parentId,
|
||||||
|
assignee_id: assigneeId || undefined,
|
||||||
|
reviewer_id: reviewerId || undefined,
|
||||||
|
labels: selectedLabels,
|
||||||
|
});
|
||||||
|
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-lg p-6 max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-bold mb-4">
|
||||||
|
{parentKey ? `Подзадача для ${parentKey}` : "Новая задача"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--muted)] mb-1 block">Тип</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TYPES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setType(t.key)}
|
||||||
|
className={`px-2 py-1 rounded text-xs transition-colors
|
||||||
|
${type === t.key ? "bg-[var(--accent)] text-white" : "bg-white/5 text-[var(--muted)] hover:bg-white/10"}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status + Priority row */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-[var(--muted)] mb-1 block">Статус</label>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
onClick={() => setStatus(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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Assignee + Reviewer */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-[var(--muted)] mb-1 block">Исполнитель</label>
|
||||||
|
<select
|
||||||
|
value={assigneeId}
|
||||||
|
onChange={(e) => setAssigneeId(e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Не назначен</option>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.name} ({m.slug})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-[var(--muted)] mb-1 block">Ревьюер</label>
|
||||||
|
<select
|
||||||
|
value={reviewerId}
|
||||||
|
onChange={(e) => setReviewerId(e.target.value)}
|
||||||
|
className="w-full px-2 py-1.5 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Не назначен</option>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.name} ({m.slug})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
{labels.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-[var(--muted)] mb-1 block">Лейблы</label>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{labels.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedLabels(prev =>
|
||||||
|
prev.includes(l.name) ? prev.filter(n => n !== l.name) : [...prev, l.name]
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors border ${
|
||||||
|
selectedLabels.includes(l.name)
|
||||||
|
? "border-[var(--accent)] bg-[var(--accent)]/20"
|
||||||
|
: "border-[var(--border)] bg-white/5 text-[var(--muted)] hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: l.color }} />
|
||||||
|
{l.name}
|
||||||
|
</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,242 @@
|
|||||||
|
|
||||||
|
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, 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: Task) => {
|
||||||
|
if (data.project_id === projectId) {
|
||||||
|
setTasks((prev) => prev.some((t) => t.id === data.id) ? prev : [...prev, data]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribeUpdated = wsClient.on("task.updated", (data: Task) => {
|
||||||
|
if (data.project_id === projectId) {
|
||||||
|
setTasks((prev) => prev.map((t) => t.id === data.id ? data : t));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribeAssigned = wsClient.on("task.assigned", (data: Task) => {
|
||||||
|
if (data.project_id === projectId) {
|
||||||
|
setTasks((prev) => prev.map((t) => t.id === data.id ? data : t));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeCreated?.();
|
||||||
|
unsubscribeUpdated?.();
|
||||||
|
unsubscribeAssigned?.();
|
||||||
|
};
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const handleDrop = async (status: string) => {
|
||||||
|
if (!draggedTask) return;
|
||||||
|
const task = tasks.find((t) => t.id === draggedTask);
|
||||||
|
if (!task || task.status === status) return;
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === draggedTask ? { ...t, status } : t)));
|
||||||
|
setDraggedTask(null);
|
||||||
|
try { await updateTask(draggedTask, { status }); } catch { loadTasks(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveTask = async (taskId: string, newStatus: string) => {
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
|
||||||
|
try { await updateTask(taskId, { status: newStatus }); } catch { loadTasks(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center h-64 text-[var(--muted)]">Загрузка...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskCard = ({ task, showMoveButtons, colIndex }: { task: Task; showMoveButtons?: boolean; colIndex?: number }) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDraggedTask(task.id)}
|
||||||
|
onClick={() => setSelectedTask(task)}
|
||||||
|
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 cursor-pointer
|
||||||
|
hover:border-[var(--accent)] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<div className="w-2 h-2 rounded-full shrink-0"
|
||||||
|
style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }}
|
||||||
|
title={task.priority} />
|
||||||
|
<span className="text-xs text-[var(--muted)]">
|
||||||
|
{task.parent_key && <span>{task.parent_key} / </span>}
|
||||||
|
{prefix}-{task.number}
|
||||||
|
</span>
|
||||||
|
{task.assignee && (
|
||||||
|
<span className="text-xs text-[var(--muted)] ml-auto">→ {task.assignee.slug}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm ml-3.5">{task.title}</div>
|
||||||
|
{task.labels && task.labels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 ml-3.5 mt-1">
|
||||||
|
{task.labels.map((label: string) => (
|
||||||
|
<span key={label} className="text-[10px] px-1.5 py-0.5 rounded-full bg-[var(--accent)]/20 text-[var(--accent)]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showMoveButtons && colIndex !== undefined && (
|
||||||
|
<div className="flex gap-1 mt-2 ml-3.5">
|
||||||
|
{colIndex > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleMoveTask(task.id, COLUMNS[colIndex - 1].key); }}
|
||||||
|
className="text-xs px-2 py-0.5 rounded bg-white/5 text-[var(--muted)] hover:text-[var(--fg)]"
|
||||||
|
>← {COLUMNS[colIndex - 1].label}</button>
|
||||||
|
)}
|
||||||
|
{colIndex < COLUMNS.length - 1 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleMoveTask(task.id, COLUMNS[colIndex + 1].key); }}
|
||||||
|
className="text-xs px-2 py-0.5 rounded bg-white/5 text-[var(--muted)] hover:text-[var(--fg)]"
|
||||||
|
>{COLUMNS[colIndex + 1].label} →</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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);
|
||||||
|
}}
|
||||||
|
onOpenTask={(taskId) => {
|
||||||
|
const t = tasks.find(t => t.id === taskId);
|
||||||
|
if (t) setSelectedTask(t);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create task modal */}
|
||||||
|
{createInStatus && (
|
||||||
|
<CreateTaskModal
|
||||||
|
projectId={projectId}
|
||||||
|
initialStatus={createInStatus}
|
||||||
|
onClose={() => setCreateInStatus(null)}
|
||||||
|
onCreated={(task) => setTasks((prev) => prev.some((t) => t.id === task.id) ? prev : [...prev, task])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
157
web-client-vite/src/components/MentionInput.tsx
Normal file
157
web-client-vite/src/components/MentionInput.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { ProjectMember } from "@/lib/api";
|
||||||
|
import { getProjectMembers } from "@/lib/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onMentionInsert?: (memberId: string, slug: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MentionInput({ projectId, value, onChange, onSubmit, onMentionInsert, placeholder, disabled }: Props) {
|
||||||
|
const [members, setMembers] = useState<ProjectMember[]>([]);
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||||
|
const [mentionStart, setMentionStart] = useState(-1);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Load members once
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) return;
|
||||||
|
getProjectMembers(projectId).then(setMembers).catch(() => {});
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const filtered = members.filter((m) =>
|
||||||
|
m.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
|
m.slug.toLowerCase().includes(filter.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertMention = useCallback((member: ProjectMember) => {
|
||||||
|
if (mentionStart < 0) return;
|
||||||
|
const before = value.slice(0, mentionStart);
|
||||||
|
const after = value.slice(inputRef.current?.selectionStart ?? value.length);
|
||||||
|
const newValue = `${before}@${member.slug} ${after}`;
|
||||||
|
onChange(newValue);
|
||||||
|
onMentionInsert?.(member.id, member.slug);
|
||||||
|
setShowDropdown(false);
|
||||||
|
setMentionStart(-1);
|
||||||
|
// Focus and set cursor after mention
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = inputRef.current;
|
||||||
|
if (el) {
|
||||||
|
const pos = before.length + member.slug.length + 2; // @slug + space
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(pos, pos);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [value, mentionStart, onChange]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newVal = e.target.value;
|
||||||
|
onChange(newVal);
|
||||||
|
|
||||||
|
const cursorPos = e.target.selectionStart;
|
||||||
|
// Find @ before cursor
|
||||||
|
const textBeforeCursor = newVal.slice(0, cursorPos);
|
||||||
|
const lastAt = textBeforeCursor.lastIndexOf("@");
|
||||||
|
|
||||||
|
if (lastAt >= 0) {
|
||||||
|
// Check: @ must be at start or after whitespace
|
||||||
|
const charBefore = lastAt > 0 ? textBeforeCursor[lastAt - 1] : " ";
|
||||||
|
if (/\s/.test(charBefore) || lastAt === 0) {
|
||||||
|
const query = textBeforeCursor.slice(lastAt + 1);
|
||||||
|
// No spaces in mention query
|
||||||
|
if (!/\s/.test(query)) {
|
||||||
|
setMentionStart(lastAt);
|
||||||
|
setFilter(query);
|
||||||
|
setShowDropdown(true);
|
||||||
|
setSelectedIdx(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowDropdown(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (showDropdown && filtered.length > 0) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIdx((i) => (i + 1) % filtered.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIdx((i) => (i - 1 + filtered.length) % filtered.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" || e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
insertMention(filtered[selectedIdx]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowDropdown(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Enter" && !e.shiftKey && !showDropdown) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON: Record<string, string> = { human: "👤", agent: "🤖" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={1}
|
||||||
|
className="w-full bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)] resize-none"
|
||||||
|
style={{ minHeight: "38px", maxHeight: "120px" }}
|
||||||
|
onInput={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 120) + "px";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showDropdown && filtered.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute bottom-full left-0 mb-1 w-64 bg-[var(--surface)] border border-[var(--border)] rounded-lg shadow-lg overflow-hidden z-50"
|
||||||
|
>
|
||||||
|
{filtered.map((m, i) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm flex items-center gap-2 hover:bg-white/10 ${
|
||||||
|
i === selectedIdx ? "bg-white/10" : ""
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // prevent blur
|
||||||
|
insertMention(m);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{ICON[m.type] || "👤"}</span>
|
||||||
|
<span className="font-medium">{m.name}</span>
|
||||||
|
<span className="text-[var(--muted)]">@{m.slug}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,17 +1,268 @@
|
|||||||
import type { Project } from "@/lib/api";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { Project, ProjectFile } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
getProjectFiles,
|
||||||
|
uploadProjectFile,
|
||||||
|
getProjectFileUrl,
|
||||||
|
updateProjectFile,
|
||||||
|
deleteProjectFile,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(mime: string | null, filename: string): string {
|
||||||
|
if (mime?.startsWith("image/")) return "🖼️";
|
||||||
|
if (mime?.startsWith("video/")) return "🎬";
|
||||||
|
if (mime?.startsWith("audio/")) return "🎵";
|
||||||
|
if (mime === "application/pdf") return "📕";
|
||||||
|
const ext = filename.split(".").pop()?.toLowerCase();
|
||||||
|
if (["md", "txt", "rst"].includes(ext || "")) return "📝";
|
||||||
|
if (["json", "yaml", "yml", "toml", "xml"].includes(ext || "")) return "⚙️";
|
||||||
|
if (["py", "ts", "js", "rs", "go", "java", "c", "cpp", "h"].includes(ext || "")) return "💻";
|
||||||
|
if (["zip", "tar", "gz", "rar", "7z"].includes(ext || "")) return "📦";
|
||||||
|
if (["doc", "docx", "odt"].includes(ext || "")) return "📝";
|
||||||
|
if (["xls", "xlsx", "csv"].includes(ext || "")) return "📊";
|
||||||
|
return "📄";
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProjectFiles({ project }: Props) {
|
export default function ProjectFiles({ project }: Props) {
|
||||||
|
const [files, setFiles] = useState<ProjectFile[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editDesc, setEditDesc] = useState("");
|
||||||
|
const [uploadDesc, setUploadDesc] = useState("");
|
||||||
|
const [showUploadDesc, setShowUploadDesc] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const loadFiles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await getProjectFiles(project.id, search || undefined);
|
||||||
|
setFiles(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load files:", e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [project.id, search]);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
const timer = setTimeout(loadFiles, search ? 300 : 0);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [loadFiles, search]);
|
||||||
|
|
||||||
|
const handleUpload = async (fileList: FileList) => {
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(fileList)) {
|
||||||
|
await uploadProjectFile(project.id, file, uploadDesc || undefined);
|
||||||
|
}
|
||||||
|
setUploadDesc("");
|
||||||
|
setShowUploadDesc(false);
|
||||||
|
await loadFiles();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Upload failed:", e);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (f: ProjectFile) => {
|
||||||
|
if (!confirm(`Удалить ${f.filename}?`)) return;
|
||||||
|
try {
|
||||||
|
await deleteProjectFile(project.id, f.id);
|
||||||
|
setFiles((prev) => prev.filter((x) => x.id !== f.id));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Delete failed:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDesc = async (f: ProjectFile) => {
|
||||||
|
try {
|
||||||
|
const updated = await updateProjectFile(project.id, f.id, { description: editDesc });
|
||||||
|
setFiles((prev) => prev.map((x) => (x.id === f.id ? updated : x)));
|
||||||
|
setEditingId(null);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Update failed:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
if (e.dataTransfer.files.length) handleUpload(e.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-[var(--muted)]">
|
<div
|
||||||
<div className="text-center">
|
className={`flex flex-col h-full ${dragOver ? "ring-2 ring-[var(--accent)] ring-inset" : ""}`}
|
||||||
<h3 className="text-lg mb-2">📁 Project Files</h3>
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
<p className="text-sm">Project: {project.name}</p>
|
onDragLeave={() => setDragOver(false)}
|
||||||
<p className="text-xs mt-1">Coming soon...</p>
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-[var(--border)] flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
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)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowUploadDesc(!showUploadDesc)}
|
||||||
|
className={`text-xs transition-colors ${showUploadDesc ? "text-[var(--accent)]" : "text-[var(--muted)] hover:text-[var(--fg)]"}`}
|
||||||
|
title="Добавить описание при загрузке"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
{uploading ? "⏳ Загрузка..." : "📎 Загрузить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showUploadDesc && (
|
||||||
|
<input
|
||||||
|
value={uploadDesc}
|
||||||
|
onChange={(e) => setUploadDesc(e.target.value)}
|
||||||
|
placeholder="Описание для загружаемых файлов (опционально)"
|
||||||
|
className="bg-[var(--bg)] border border-[var(--border)] rounded-lg px-4 py-2 text-sm outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center text-sm text-[var(--muted)] py-8">Загрузка...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && files.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-[var(--muted)]">
|
||||||
|
<div className="text-4xl mb-4">📁</div>
|
||||||
|
<div className="text-sm">Нет файлов</div>
|
||||||
|
<div className="text-xs mt-1">Перетащите файлы сюда или нажмите «Загрузить»</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && files.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{files.map((f) => {
|
||||||
|
const url = getProjectFileUrl(project.id, f.id);
|
||||||
|
const isImage = f.mime_type?.startsWith("image/");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 group"
|
||||||
|
>
|
||||||
|
{/* Icon / thumbnail */}
|
||||||
|
<div className="shrink-0 w-8 text-center">
|
||||||
|
{isImage ? (
|
||||||
|
<img src={url} alt="" className="w-8 h-8 rounded object-cover" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span className="text-lg">{fileIcon(f.mime_type, f.filename)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm font-medium text-[var(--fg)] hover:text-[var(--accent)] truncate"
|
||||||
|
>
|
||||||
|
{f.filename}
|
||||||
|
</a>
|
||||||
|
<span className="text-xs text-[var(--muted)]">{formatSize(f.size)}</span>
|
||||||
|
</div>
|
||||||
|
{editingId === f.id ? (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<input
|
||||||
|
value={editDesc}
|
||||||
|
onChange={(e) => setEditDesc(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSaveDesc(f);
|
||||||
|
if (e.key === "Escape") setEditingId(null);
|
||||||
|
}}
|
||||||
|
placeholder="Описание файла..."
|
||||||
|
className="flex-1 bg-[var(--bg)] border border-[var(--border)] rounded px-2 py-0.5 text-xs outline-none focus:border-[var(--accent)]"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={() => handleSaveDesc(f)} className="text-xs text-green-400 hover:text-green-300">✓</button>
|
||||||
|
<button onClick={() => setEditingId(null)} className="text-xs text-[var(--muted)] hover:text-[var(--fg)]">✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
f.description && (
|
||||||
|
<div className="text-xs text-[var(--muted)] truncate mt-0.5">{f.description}</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uploader */}
|
||||||
|
<div className="text-xs text-[var(--muted)] hidden md:block shrink-0">
|
||||||
|
{f.uploaded_by?.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div className="text-xs text-[var(--muted)] hidden md:block shrink-0">
|
||||||
|
{new Date(f.created_at).toLocaleDateString("ru-RU")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions — visible on hover */}
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingId(f.id); setEditDesc(f.description || ""); }}
|
||||||
|
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] p-1"
|
||||||
|
title="Описание"
|
||||||
|
>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(f)}
|
||||||
|
className="text-xs text-[var(--muted)] hover:text-red-400 p-1"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer stats */}
|
||||||
|
{!loading && files.length > 0 && (
|
||||||
|
<div className="px-6 py-2 border-t border-[var(--border)] text-xs text-[var(--muted)]">
|
||||||
|
{files.length} файл{files.length === 1 ? "" : files.length < 5 ? "а" : "ов"} · {formatSize(files.reduce((s, f) => s + f.size, 0))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@ -1,17 +1,323 @@
|
|||||||
import type { Project } from "@/lib/api";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import type { Project, ProjectMember, Member } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
updateProject,
|
||||||
|
deleteProject,
|
||||||
|
getProjectMembers,
|
||||||
|
addProjectMember,
|
||||||
|
removeProjectMember,
|
||||||
|
getMembers,
|
||||||
|
} 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 }: 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 [autoAssign, setAutoAssign] = useState(project.auto_assign || false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
// Members state
|
||||||
|
const [members, setMembers] = useState<ProjectMember[]>([]);
|
||||||
|
const [allMembers, setAllMembers] = useState<Member[]>([]);
|
||||||
|
const [loadingMembers, setLoadingMembers] = useState(true);
|
||||||
|
const [selectedMember, setSelectedMember] = useState("");
|
||||||
|
|
||||||
|
// Labels removed — now in app settings
|
||||||
|
|
||||||
|
// Load project members and all members
|
||||||
|
useEffect(() => {
|
||||||
|
const loadMembers = async () => {
|
||||||
|
try {
|
||||||
|
const [projectMembers, allMembersList] = await Promise.all([
|
||||||
|
getProjectMembers(project.id),
|
||||||
|
getMembers()
|
||||||
|
]);
|
||||||
|
setMembers(projectMembers);
|
||||||
|
setAllMembers(allMembersList);
|
||||||
|
// Labels now in app settings
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load members:", e);
|
||||||
|
} finally {
|
||||||
|
setLoadingMembers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadMembers();
|
||||||
|
}, [project.slug]);
|
||||||
|
|
||||||
|
// Get available members for adding (not already in project)
|
||||||
|
const availableMembers = allMembers.filter(
|
||||||
|
(member) => !members.some((pm) => pm.slug === member.slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAddMember = async () => {
|
||||||
|
if (!selectedMember) return;
|
||||||
|
try {
|
||||||
|
await addProjectMember(project.id, selectedMember);
|
||||||
|
const newMember = allMembers.find((m) => m.id === selectedMember);
|
||||||
|
if (newMember) {
|
||||||
|
setMembers([...members, {
|
||||||
|
id: newMember.id,
|
||||||
|
name: newMember.name,
|
||||||
|
slug: newMember.slug,
|
||||||
|
type: newMember.type,
|
||||||
|
role: "member"
|
||||||
|
}]);
|
||||||
|
setSelectedMember("");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to add member:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// handleRemoveMember is now inline in agent toggle
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const urls = repoUrls.split("\n").map((u) => u.trim()).filter(Boolean);
|
||||||
|
const updated = await updateProject(project.id, {
|
||||||
|
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.id);
|
||||||
|
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 pb-20 space-y-6 overflow-y-auto h-full">
|
||||||
<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>
|
||||||
|
|
||||||
|
{/* Auto-assign toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm">Авто-назначение</div>
|
||||||
|
<div className="text-xs text-[var(--muted)]">Назначать задачи агентам по лейблам ↔ capabilities</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const newVal = !autoAssign;
|
||||||
|
setAutoAssign(newVal);
|
||||||
|
try {
|
||||||
|
const updated = await updateProject(project.id, { auto_assign: newVal });
|
||||||
|
onUpdated(updated);
|
||||||
|
} catch (e) { setAutoAssign(!newVal); }
|
||||||
|
}}
|
||||||
|
className={`relative w-10 h-5 rounded-full transition-colors duration-200 ${
|
||||||
|
autoAssign ? "bg-[var(--accent)]" : "bg-[var(--border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform duration-200 ${
|
||||||
|
autoAssign ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Labels Section */}
|
||||||
|
{/* Members Section */}
|
||||||
|
<div className="pt-6 border-t border-[var(--border)]">
|
||||||
|
<div className="text-sm font-medium mb-3">Участники</div>
|
||||||
|
|
||||||
|
{loadingMembers ? (
|
||||||
|
<div className="text-sm text-[var(--muted)]">Загрузка...</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Humans (non-removable except by dropdown) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{members.filter(m => m.type !== "agent").map((member) => (
|
||||||
|
<div key={member.slug} className="flex items-center justify-between p-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium bg-green-500/20 text-green-400">
|
||||||
|
H
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{member.name}</div>
|
||||||
|
<div className="text-xs text-[var(--muted)]">{member.slug} • {member.role}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agents with toggle switches */}
|
||||||
|
<div className="text-xs text-[var(--muted)] mt-4 mb-2">Агенты</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{allMembers.filter(m => m.type === "agent").map((agent) => {
|
||||||
|
const isActive = members.some(pm => pm.id === agent.id);
|
||||||
|
return (
|
||||||
|
<div key={agent.id} className="flex items-center justify-between p-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium bg-blue-500/20 text-blue-400">
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{agent.name}</div>
|
||||||
|
<div className="text-xs text-[var(--muted)]">{agent.slug}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
if (isActive) {
|
||||||
|
await removeProjectMember(project.id, agent.id);
|
||||||
|
setMembers(members.filter(m => m.id !== agent.id));
|
||||||
|
} else {
|
||||||
|
await addProjectMember(project.id, agent.id);
|
||||||
|
setMembers([...members, { id: agent.id, name: agent.name, slug: agent.slug, type: agent.type, role: "member" } as ProjectMember]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to toggle agent:", e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`relative w-10 h-5 rounded-full transition-colors duration-200 ${
|
||||||
|
isActive ? "bg-[var(--accent)]" : "bg-[var(--border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform duration-200 ${
|
||||||
|
isActive ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add human member */}
|
||||||
|
{availableMembers.filter(m => m.type !== "agent").length > 0 && (
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<select
|
||||||
|
value={selectedMember}
|
||||||
|
onChange={(e) => setSelectedMember(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Добавить участника...</option>
|
||||||
|
{availableMembers.filter(m => m.type !== "agent").map((member) => (
|
||||||
|
<option key={member.id} value={member.id}>
|
||||||
|
{member.name} ({member.slug}) - {member.type}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleAddMember}
|
||||||
|
disabled={!selectedMember}
|
||||||
|
className="px-3 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<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";
|
||||||
@ -5,10 +6,10 @@ import { logout } from "@/lib/auth-client";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
activeSlug?: string;
|
activeId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({ projects, activeSlug }: Props) {
|
export default function Sidebar({ projects, activeId }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const nav = (
|
const nav = (
|
||||||
@ -41,10 +42,10 @@ export default function Sidebar({ projects, activeSlug }: Props) {
|
|||||||
{projects.map((p) => (
|
{projects.map((p) => (
|
||||||
<Link
|
<Link
|
||||||
key={p.id}
|
key={p.id}
|
||||||
to={`/projects/${p.slug}`}
|
to={`/projects/${p.id}`}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className={`block px-3 py-2 rounded text-sm transition-colors ${
|
className={`block px-3 py-2 rounded text-sm transition-colors ${
|
||||||
activeSlug === p.slug
|
activeId === p.id
|
||||||
? "bg-[var(--accent)]/10 text-[var(--accent)]"
|
? "bg-[var(--accent)]/10 text-[var(--accent)]"
|
||||||
: "hover:bg-white/5 text-[var(--fg)]"
|
: "hover:bg-white/5 text-[var(--fg)]"
|
||||||
}`}
|
}`}
|
||||||
@ -98,7 +99,7 @@ export default function Sidebar({ projects, activeSlug }: Props) {
|
|||||||
{/* Sidebar: always visible on desktop, slide-in on mobile */}
|
{/* Sidebar: always visible on desktop, slide-in on mobile */}
|
||||||
<aside
|
<aside
|
||||||
className={`
|
className={`
|
||||||
fixed md:static z-50 top-0 left-0 h-screen w-60 shrink-0
|
fixed md:static z-50 top-0 left-0 h-[100dvh] w-60 shrink-0
|
||||||
border-r border-[var(--border)] bg-[var(--card)] flex flex-col
|
border-r border-[var(--border)] bg-[var(--card)] flex flex-col
|
||||||
transition-transform duration-200
|
transition-transform duration-200
|
||||||
${open ? "translate-x-0" : "-translate-x-full md:translate-x-0"}
|
${open ? "translate-x-0" : "-translate-x-full md:translate-x-0"}
|
||||||
|
|||||||
526
web-client-vite/src/components/TaskModal.tsx
Normal file
526
web-client-vite/src/components/TaskModal.tsx
Normal file
@ -0,0 +1,526 @@
|
|||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { Task, Member, Step, Message, TaskLink } from "@/lib/api";
|
||||||
|
import CreateTaskModal from "@/components/CreateTaskModal";
|
||||||
|
import {
|
||||||
|
updateTask, deleteTask, getMembers,
|
||||||
|
getSteps, createStep, updateStep, deleteStep as _deleteStepApi,
|
||||||
|
getMessages, sendMessage,
|
||||||
|
getTaskLinks, createTaskLink, deleteTaskLink,
|
||||||
|
searchTasks,
|
||||||
|
} 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;
|
||||||
|
onOpenTask?: (taskId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskModal({ task, projectId: _projectId, projectSlug: _projectSlug, onClose, onUpdated, onDeleted, onOpenTask }: 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 [assigneeId, setAssigneeId] = useState(task.assignee_id || "");
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [steps, setSteps] = useState<Step[]>(task.steps || []);
|
||||||
|
const [links, setLinks] = useState<TaskLink[]>([]);
|
||||||
|
const [showAddLink, setShowAddLink] = useState(false);
|
||||||
|
const [linkTargetId, setLinkTargetId] = useState("");
|
||||||
|
const [linkSearch, setLinkSearch] = useState("");
|
||||||
|
const [linkSearchResults, setLinkSearchResults] = useState<Task[]>([]);
|
||||||
|
const [linkType, setLinkType] = useState("depends_on");
|
||||||
|
const [comments, setComments] = useState<Message[]>([]);
|
||||||
|
const [_saving, setSaving] = useState(false);
|
||||||
|
const [editingDesc, setEditingDesc] = useState(false);
|
||||||
|
const [editingTitle, setEditingTitle] = useState(false);
|
||||||
|
const [showCreateSubtask, setShowCreateSubtask] = 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(() => {});
|
||||||
|
getTaskLinks(task.id).then(setLinks).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-center justify-between mb-1">
|
||||||
|
<div className="text-xs text-[var(--muted)]">
|
||||||
|
{task.parent_key && task.parent_id ? (
|
||||||
|
<span>
|
||||||
|
<button
|
||||||
|
onClick={() => { onClose(); setTimeout(() => onOpenTask?.(task.parent_id!), 100); }}
|
||||||
|
className="text-[var(--accent)] hover:underline"
|
||||||
|
>{task.parent_key}</button>
|
||||||
|
<span className="mx-1">/</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{task.key}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-[var(--muted)] hover:text-[var(--fg)] transition-colors text-xl leading-none cursor-pointer shrink-0"
|
||||||
|
title="Закрыть"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Subtasks */}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-[var(--muted)] mb-2 flex items-center justify-between">
|
||||||
|
<span>Подзадачи{task.subtasks?.length ? ` (${task.subtasks.length})` : ""}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateSubtask(true)}
|
||||||
|
className="text-xs text-[var(--accent)] hover:underline"
|
||||||
|
>+ Подзадача</button>
|
||||||
|
</div>
|
||||||
|
{task.subtasks && task.subtasks.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{task.subtasks.map((sub) => (
|
||||||
|
<div key={sub.id} className="flex items-center justify-between p-1.5 bg-[var(--bg)] border border-[var(--border)] rounded text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
|
sub.status === "done" ? "bg-green-500" :
|
||||||
|
sub.status === "in_progress" ? "bg-blue-500" :
|
||||||
|
sub.status === "in_review" ? "bg-yellow-500" : "bg-gray-500"
|
||||||
|
}`} />
|
||||||
|
<button
|
||||||
|
onClick={() => { onClose(); setTimeout(() => onOpenTask?.(sub.id), 100); }}
|
||||||
|
className="text-[var(--accent)] hover:underline font-mono"
|
||||||
|
>{sub.key}</button>
|
||||||
|
<span>{sub.title}</span>
|
||||||
|
</div>
|
||||||
|
{sub.assignee && (
|
||||||
|
<span className="text-[var(--muted)]">→ {sub.assignee.slug}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreateSubtask && (
|
||||||
|
<CreateTaskModal
|
||||||
|
projectId={task.project_id}
|
||||||
|
initialStatus="todo"
|
||||||
|
parentId={task.id}
|
||||||
|
parentKey={task.key}
|
||||||
|
onClose={() => setShowCreateSubtask(false)}
|
||||||
|
onCreated={(newTask) => {
|
||||||
|
// Refresh parent task to show new subtask
|
||||||
|
onUpdated({ ...task, subtasks: [...(task.subtasks || []), { id: newTask.id, key: newTask.key, title: newTask.title, status: newTask.status, assignee: newTask.assignee }] });
|
||||||
|
setShowCreateSubtask(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dependencies */}
|
||||||
|
{(links.length > 0 || showAddLink) && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-[var(--muted)] mb-2">Зависимости</div>
|
||||||
|
<div className="space-y-1 mb-2">
|
||||||
|
{links.map((link) => {
|
||||||
|
const label = link.link_type === "blocks" ? "🔴 Блокирует" :
|
||||||
|
link.link_type === "blocked_by" ? "🟡 Заблокировано" :
|
||||||
|
link.link_type === "depends_on" ? "🟡 Зависит от" :
|
||||||
|
link.link_type === "required_by" ? "🔵 Требуется для" :
|
||||||
|
"🔗 Связано с";
|
||||||
|
const title = link.target_title || link.source_title || "...";
|
||||||
|
return (
|
||||||
|
<div key={link.id} className="flex items-center justify-between text-xs p-1.5 bg-[var(--bg)] border border-[var(--border)] rounded">
|
||||||
|
<span>{label}: <span className="font-medium">{title}</span></span>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteTaskLink(task.id, link.id);
|
||||||
|
setLinks(links.filter(l => l.id !== link.id));
|
||||||
|
}}
|
||||||
|
className="text-red-400 hover:text-red-300 ml-2"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{showAddLink && (
|
||||||
|
<div className="flex gap-1 mb-2">
|
||||||
|
<select value={linkType} onChange={e => setLinkType(e.target.value)}
|
||||||
|
className="px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-xs">
|
||||||
|
<option value="depends_on">Зависит от</option>
|
||||||
|
<option value="blocks">Блокирует</option>
|
||||||
|
<option value="relates_to">Связано с</option>
|
||||||
|
</select>
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<input
|
||||||
|
value={linkSearch}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setLinkSearch(v);
|
||||||
|
if (v.length >= 1) {
|
||||||
|
const results = await searchTasks(task.project_id, v);
|
||||||
|
setLinkSearchResults(results.filter(t => t.id !== task.id));
|
||||||
|
} else {
|
||||||
|
setLinkSearchResults([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Поиск по номеру или названию..."
|
||||||
|
className="w-full px-2 py-1 bg-[var(--bg)] border border-[var(--border)] rounded text-xs outline-none"
|
||||||
|
/>
|
||||||
|
{linkSearchResults.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-[var(--card)] border border-[var(--border)] rounded shadow-lg z-10 max-h-40 overflow-y-auto">
|
||||||
|
{linkSearchResults.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => {
|
||||||
|
setLinkTargetId(t.id);
|
||||||
|
setLinkSearch(`${t.key} ${t.title}`);
|
||||||
|
setLinkSearchResults([]);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-2 py-1.5 text-xs hover:bg-white/5 flex gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-[var(--accent)] font-mono shrink-0">{t.key}</span>
|
||||||
|
<span className="truncate">{t.title}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!linkTargetId) return;
|
||||||
|
const link = await createTaskLink(task.id, linkTargetId, linkType);
|
||||||
|
setLinks([...links, link]);
|
||||||
|
setLinkTargetId("");
|
||||||
|
setLinkSearch("");
|
||||||
|
setShowAddLink(false);
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 bg-[var(--accent)] text-white rounded text-xs"
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddLink(!showAddLink)}
|
||||||
|
className="text-xs text-[var(--accent)] hover:underline mb-2"
|
||||||
|
>
|
||||||
|
{showAddLink ? "Скрыть" : "+ Добавить зависимость"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 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_type === "system" ? "System" : (msg.author?.name || msg.author?.slug || "Unknown")}</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={assigneeId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAssigneeId(e.target.value);
|
||||||
|
save({ assignee_id: 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.id} value={m.id}>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,6 +9,17 @@ function getToken(): string | null {
|
|||||||
return localStorage.getItem("tb_token");
|
return localStorage.getItem("tb_token");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCurrentMemberId(): string {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return "unknown";
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||||
|
return payload.sub || "unknown";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T = any>(path: string, options: RequestInit = {}): Promise<T> {
|
async function request<T = any>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
@ -33,8 +44,17 @@ async function request<T = any>(path: string, options: RequestInit = {}): Promis
|
|||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
|
// --- Shared types (1:1 with backend Pydantic schemas) ---
|
||||||
|
|
||||||
|
export interface MemberBrief {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentConfig {
|
export interface AgentConfig {
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
|
labels: string[];
|
||||||
chat_listen: string;
|
chat_listen: string;
|
||||||
task_listen: string;
|
task_listen: string;
|
||||||
prompt: string | null;
|
prompt: string | null;
|
||||||
@ -43,14 +63,15 @@ export interface AgentConfig {
|
|||||||
|
|
||||||
export interface Member {
|
export interface Member {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
|
||||||
slug: string;
|
slug: string;
|
||||||
type: "human" | "agent";
|
name: string;
|
||||||
|
type: string;
|
||||||
role: string;
|
role: string;
|
||||||
status: string;
|
status: string;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
agent_config: AgentConfig | null;
|
is_active: boolean;
|
||||||
token?: string | null;
|
token?: string | null;
|
||||||
|
agent_config?: AgentConfig | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemberCreateResponse extends Member {
|
export interface MemberCreateResponse extends Member {
|
||||||
@ -66,13 +87,24 @@ export interface Project {
|
|||||||
status: string;
|
status: string;
|
||||||
task_counter: number;
|
task_counter: number;
|
||||||
chat_id: string | null;
|
chat_id: string | null;
|
||||||
|
auto_assign: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectMember {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
type: string;
|
||||||
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Step {
|
export interface Step {
|
||||||
id: string;
|
id: string;
|
||||||
|
task_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
@ -87,13 +119,28 @@ export interface Task {
|
|||||||
status: string;
|
status: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
labels: string[];
|
labels: string[];
|
||||||
assignee_slug: string | null;
|
assignee_id: string | null;
|
||||||
reviewer_slug: string | null;
|
assignee: MemberBrief | null;
|
||||||
watchers: string[];
|
reviewer_id: string | null;
|
||||||
|
reviewer: MemberBrief | null;
|
||||||
|
watcher_ids: string[];
|
||||||
depends_on: string[];
|
depends_on: string[];
|
||||||
position: number;
|
position: number;
|
||||||
time_spent: number;
|
time_spent: number;
|
||||||
steps: Step[];
|
steps: Step[];
|
||||||
|
subtasks: SubtaskBrief[];
|
||||||
|
parent_key: string | null;
|
||||||
|
parent_title: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubtaskBrief {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
assignee: MemberBrief | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
@ -109,9 +156,13 @@ export interface Message {
|
|||||||
task_id: string | null;
|
task_id: string | null;
|
||||||
parent_id: string | null;
|
parent_id: string | null;
|
||||||
author_type: string;
|
author_type: string;
|
||||||
author_slug: string;
|
author_id: string | null;
|
||||||
|
author: MemberBrief | null;
|
||||||
content: string;
|
content: string;
|
||||||
mentions: string[];
|
thinking: string | null;
|
||||||
|
tool_log: Array<{name: string; args?: string; result?: string; error?: boolean}> | null;
|
||||||
|
mentions: MemberBrief[];
|
||||||
|
actor: MemberBrief | null;
|
||||||
voice_url: string | null;
|
voice_url: string | null;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@ -120,7 +171,7 @@ export interface Message {
|
|||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
|
|
||||||
export async function login(login: string, password: string) {
|
export async function login(login: string, password: string) {
|
||||||
return request("/api/auth/login", {
|
return request("/api/v1/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ login, password }),
|
body: JSON.stringify({ login, password }),
|
||||||
});
|
});
|
||||||
@ -132,20 +183,20 @@ export async function getProjects(): Promise<Project[]> {
|
|||||||
return request("/api/v1/projects");
|
return request("/api/v1/projects");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProject(slug: string): Promise<Project> {
|
export async function getProject(projectId: string): Promise<Project> {
|
||||||
return request(`/api/v1/projects/${slug}`);
|
return request(`/api/v1/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProject(data: { name: string; slug: string; description?: string }): Promise<Project> {
|
export async function createProject(data: { name: string; slug: string; description?: string }): Promise<Project> {
|
||||||
return request("/api/v1/projects", { method: "POST", body: JSON.stringify(data) });
|
return request("/api/v1/projects", { method: "POST", body: JSON.stringify(data) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProject(slug: string, data: Partial<Pick<Project, "name" | "description" | "repo_urls" | "status">>): Promise<Project> {
|
export async function updateProject(projectId: string, data: Partial<Pick<Project, "name" | "slug" | "description" | "repo_urls" | "status" | "auto_assign">>): Promise<Project> {
|
||||||
return request(`/api/v1/projects/${slug}`, { method: "PATCH", body: JSON.stringify(data) });
|
return request(`/api/v1/projects/${projectId}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProject(slug: string): Promise<void> {
|
export async function deleteProject(projectId: string): Promise<void> {
|
||||||
await request(`/api/v1/projects/${slug}`, { method: "DELETE" });
|
await request(`/api/v1/projects/${projectId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tasks ---
|
// --- Tasks ---
|
||||||
@ -158,8 +209,8 @@ export async function getTask(taskId: string): Promise<Task> {
|
|||||||
return request(`/api/v1/tasks/${taskId}`);
|
return request(`/api/v1/tasks/${taskId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTask(projectSlug: string, data: Partial<Task>): Promise<Task> {
|
export async function createTask(projectId: string, data: Partial<Task>): Promise<Task> {
|
||||||
return request(`/api/v1/tasks?project_slug=${projectSlug}`, {
|
return request(`/api/v1/tasks?project_id=${projectId}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
@ -173,8 +224,8 @@ export async function deleteTask(taskId: string): Promise<void> {
|
|||||||
await request(`/api/v1/tasks/${taskId}`, { method: "DELETE" });
|
await request(`/api/v1/tasks/${taskId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function takeTask(taskId: string, slug: string): Promise<Task> {
|
export async function takeTask(taskId: string): Promise<Task> {
|
||||||
return request(`/api/v1/tasks/${taskId}/take?slug=${slug}`, { method: "POST" });
|
return request(`/api/v1/tasks/${taskId}/take`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rejectTask(taskId: string, reason: string): Promise<{ok: boolean; reason: string; old_assignee: string}> {
|
export async function rejectTask(taskId: string, reason: string): Promise<{ok: boolean; reason: string; old_assignee: string}> {
|
||||||
@ -184,19 +235,19 @@ export async function rejectTask(taskId: string, reason: string): Promise<{ok: b
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assignTask(taskId: string, assigneeSlug: string): Promise<Task> {
|
export async function assignTask(taskId: string, assigneeId: string): Promise<Task> {
|
||||||
return request(`/api/v1/tasks/${taskId}/assign`, {
|
return request(`/api/v1/tasks/${taskId}/assign`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ assignee_slug: assigneeSlug })
|
body: JSON.stringify({ assignee_id: assigneeId })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function watchTask(taskId: string, slug: string): Promise<{ok: boolean; watchers: string[]}> {
|
export async function watchTask(taskId: string): Promise<{ok: boolean; watcher_ids: string[]}> {
|
||||||
return request(`/api/v1/tasks/${taskId}/watch?slug=${slug}`, { method: "POST" });
|
return request(`/api/v1/tasks/${taskId}/watch`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unwatchTask(taskId: string, slug: string): Promise<{ok: boolean; watchers: string[]}> {
|
export async function unwatchTask(taskId: string): Promise<{ok: boolean; watcher_ids: string[]}> {
|
||||||
return request(`/api/v1/tasks/${taskId}/watch?slug=${slug}`, { method: "DELETE" });
|
return request(`/api/v1/tasks/${taskId}/watch`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Steps ---
|
// --- Steps ---
|
||||||
@ -225,11 +276,12 @@ export async function deleteStep(taskId: string, stepId: string): Promise<void>
|
|||||||
|
|
||||||
// --- Messages (unified: chat + task comments) ---
|
// --- Messages (unified: chat + task comments) ---
|
||||||
|
|
||||||
export async function getMessages(params: { chat_id?: string; task_id?: string; limit?: number }): Promise<Message[]> {
|
export async function getMessages(params: { chat_id?: string; task_id?: string; limit?: number; offset?: number }): Promise<Message[]> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.chat_id) qs.set("chat_id", params.chat_id);
|
if (params.chat_id) qs.set("chat_id", params.chat_id);
|
||||||
if (params.task_id) qs.set("task_id", params.task_id);
|
if (params.task_id) qs.set("task_id", params.task_id);
|
||||||
if (params.limit) qs.set("limit", String(params.limit));
|
if (params.limit) qs.set("limit", String(params.limit));
|
||||||
|
if (params.offset) qs.set("offset", String(params.offset));
|
||||||
return request(`/api/v1/messages?${qs}`);
|
return request(`/api/v1/messages?${qs}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,6 +290,7 @@ export async function sendMessage(data: {
|
|||||||
task_id?: string;
|
task_id?: string;
|
||||||
content: string;
|
content: string;
|
||||||
mentions?: string[];
|
mentions?: string[];
|
||||||
|
attachments?: { file_id: string; filename: string; mime_type: string | null; size: number; storage_name: string }[];
|
||||||
}): Promise<Message> {
|
}): Promise<Message> {
|
||||||
return request("/api/v1/messages", { method: "POST", body: JSON.stringify(data) });
|
return request("/api/v1/messages", { method: "POST", body: JSON.stringify(data) });
|
||||||
}
|
}
|
||||||
@ -248,8 +301,8 @@ export async function getMembers(): Promise<Member[]> {
|
|||||||
return request("/api/v1/members");
|
return request("/api/v1/members");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMember(slug: string): Promise<Member> {
|
export async function getMember(memberId: string): Promise<Member> {
|
||||||
return request(`/api/v1/members/${slug}`);
|
return request(`/api/v1/members/${memberId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMember(data: {
|
export async function createMember(data: {
|
||||||
@ -261,19 +314,182 @@ export async function createMember(data: {
|
|||||||
return request("/api/v1/members", { method: "POST", body: JSON.stringify(data) });
|
return request("/api/v1/members", { method: "POST", body: JSON.stringify(data) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMember(slug: string, data: {
|
export async function updateMember(memberId: string, data: {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
agent_config?: Partial<AgentConfig>;
|
agent_config?: Partial<AgentConfig>;
|
||||||
}): Promise<Member> {
|
}): Promise<Member> {
|
||||||
return request(`/api/v1/members/${slug}`, { method: "PATCH", body: JSON.stringify(data) });
|
return request(`/api/v1/members/${memberId}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function regenerateToken(slug: string): Promise<{ token: string }> {
|
export async function regenerateToken(memberId: string): Promise<{ token: string }> {
|
||||||
return request(`/api/v1/members/${slug}/regenerate-token`, { method: "POST" });
|
return request(`/api/v1/members/${memberId}/regenerate-token`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeToken(slug: string): Promise<void> {
|
export async function revokeToken(memberId: string): Promise<void> {
|
||||||
await request(`/api/v1/members/${slug}/revoke-token`, { method: "POST" });
|
await request(`/api/v1/members/${memberId}/revoke-token`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Project Files ---
|
||||||
|
|
||||||
|
export interface ProjectFile {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
description: string | null;
|
||||||
|
mime_type: string | null;
|
||||||
|
size: number;
|
||||||
|
uploaded_by: MemberBrief | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectFiles(projectId: string, search?: string): Promise<ProjectFile[]> {
|
||||||
|
const qs = search ? `?search=${encodeURIComponent(search)}` : "";
|
||||||
|
return request(`/api/v1/projects/${projectId}/files${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadProjectFile(projectId: string, file: File, description?: string): Promise<ProjectFile> {
|
||||||
|
const token = getToken();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
if (description) formData.append("description", description);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/files`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem("tb_token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectFileUrl(projectId: string, fileId: string): string {
|
||||||
|
const token = getToken();
|
||||||
|
const qs = token ? `?token=${encodeURIComponent(token)}` : "";
|
||||||
|
return `${API_BASE}/api/v1/projects/${projectId}/files/${fileId}/download${qs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProjectFile(projectId: string, fileId: string, data: { description?: string }): Promise<ProjectFile> {
|
||||||
|
return request(`/api/v1/projects/${projectId}/files/${fileId}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProjectFile(projectId: string, fileId: string): Promise<void> {
|
||||||
|
await request(`/api/v1/projects/${projectId}/files/${fileId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chat File Upload ---
|
||||||
|
|
||||||
|
export interface UploadedFile {
|
||||||
|
file_id: string;
|
||||||
|
filename: string;
|
||||||
|
mime_type: string | null;
|
||||||
|
size: number;
|
||||||
|
storage_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(file: File): Promise<UploadedFile> {
|
||||||
|
const token = getToken();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/v1/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem("tb_token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentUrl(attachmentId: string): string {
|
||||||
|
const token = getToken();
|
||||||
|
const qs = token ? `?token=${encodeURIComponent(token)}` : "";
|
||||||
|
return `${API_BASE}/api/v1/attachments/${attachmentId}/download${qs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Project Members ---
|
||||||
|
|
||||||
|
export async function getProjectMembers(projectId: string): Promise<ProjectMember[]> {
|
||||||
|
return request(`/api/v1/projects/${projectId}/members`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addProjectMember(projectId: string, memberId: string): Promise<{ ok: boolean }> {
|
||||||
|
return request(`/api/v1/projects/${projectId}/members`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ member_id: memberId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeProjectMember(projectId: string, memberId: string): Promise<{ ok: boolean }> {
|
||||||
|
return request(`/api/v1/projects/${projectId}/members/${memberId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
// --- Task Links ---
|
||||||
|
export interface TaskLink {
|
||||||
|
id: string;
|
||||||
|
source_id: string;
|
||||||
|
target_id: string;
|
||||||
|
link_type: string;
|
||||||
|
target_title?: string;
|
||||||
|
source_title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskLinks(taskId: string): Promise<TaskLink[]> {
|
||||||
|
return request(`/tasks/${taskId}/links`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTaskLink(taskId: string, targetId: string, linkType: string): Promise<TaskLink> {
|
||||||
|
return request(`/tasks/${taskId}/links`, { method: "POST", body: JSON.stringify({ target_id: targetId, link_type: linkType }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTaskLink(taskId: string, linkId: string): Promise<void> {
|
||||||
|
return request(`/tasks/${taskId}/links/${linkId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Labels ---
|
||||||
|
export interface Label {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLabels(): Promise<Label[]> {
|
||||||
|
return request(`/labels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLabel(name: string, color: string = "#6366f1"): Promise<Label> {
|
||||||
|
return request(`/labels`, { method: "POST", body: JSON.stringify({ name, color }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLabel(labelId: string): Promise<void> {
|
||||||
|
return request(`/labels/${labelId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTaskLabel(taskId: string, labelId: string): Promise<void> {
|
||||||
|
return request(`/tasks/${taskId}/labels/${labelId}`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTaskLabel(taskId: string, labelId: string): Promise<void> {
|
||||||
|
return request(`/tasks/${taskId}/labels/${labelId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchTasks(projectId: string, query: string): Promise<Task[]> {
|
||||||
|
return request(`/tasks?project_id=${projectId}&q=${encodeURIComponent(query)}`);
|
||||||
}
|
}
|
||||||
@ -9,11 +9,12 @@ class WSClient {
|
|||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private handlers: Map<string, Set<MessageHandler>> = new Map();
|
private handlers: Map<string, Set<MessageHandler>> = new Map();
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private pendingQueue: any[] = [];
|
private pendingQueue: any[] = [];
|
||||||
private authenticated = false;
|
private authenticated = false;
|
||||||
private _lobbyId: string | null = null;
|
private _lobbyId: string | null = null;
|
||||||
private _projects: Array<{ id: string; slug: string; name: string }> = [];
|
private _projects: Array<{ id: string; slug: string; name: string }> = [];
|
||||||
private _online: string[] = [];
|
private _online: Array<{id: string; slug: string}> = [];
|
||||||
|
|
||||||
get lobbyId() { return this._lobbyId; }
|
get lobbyId() { return this._lobbyId; }
|
||||||
get projects() { return this._projects; }
|
get projects() { return this._projects; }
|
||||||
@ -38,7 +39,7 @@ class WSClient {
|
|||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(event.data);
|
||||||
const type = msg.type;
|
const type = msg.type;
|
||||||
|
|
||||||
// Handle auth.ok — flush pending queue
|
// Handle auth.ok — flush pending queue + start heartbeat
|
||||||
if (type === "auth.ok") {
|
if (type === "auth.ok") {
|
||||||
this._lobbyId = msg.data?.lobby_chat_id || null;
|
this._lobbyId = msg.data?.lobby_chat_id || null;
|
||||||
this._projects = msg.data?.projects || [];
|
this._projects = msg.data?.projects || [];
|
||||||
@ -49,6 +50,16 @@ class WSClient {
|
|||||||
this.ws!.send(JSON.stringify(queued));
|
this.ws!.send(JSON.stringify(queued));
|
||||||
}
|
}
|
||||||
this.pendingQueue = [];
|
this.pendingQueue = [];
|
||||||
|
// Start heartbeat every 30s to keep connection alive
|
||||||
|
this.startHeartbeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth error → force re-login
|
||||||
|
if (type === "auth.error") {
|
||||||
|
console.error("WS auth failed:", msg);
|
||||||
|
localStorage.removeItem("tb_token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch to handlers
|
// Dispatch to handlers
|
||||||
@ -65,6 +76,7 @@ class WSClient {
|
|||||||
|
|
||||||
this.ws.onclose = () => {
|
this.ws.onclose = () => {
|
||||||
this.authenticated = false;
|
this.authenticated = false;
|
||||||
|
this.stopHeartbeat();
|
||||||
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
|
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -73,12 +85,27 @@ class WSClient {
|
|||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||||
|
this.stopHeartbeat();
|
||||||
this.ws?.close();
|
this.ws?.close();
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this.authenticated = false;
|
this.authenticated = false;
|
||||||
this.pendingQueue = [];
|
this.pendingQueue = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startHeartbeat() {
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
this.heartbeat();
|
||||||
|
}, 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopHeartbeat() {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
on(type: string, handler: MessageHandler) {
|
on(type: string, handler: MessageHandler) {
|
||||||
if (!this.handlers.has(type)) this.handlers.set(type, new Set());
|
if (!this.handlers.has(type)) this.handlers.set(type, new Set());
|
||||||
this.handlers.get(type)!.add(handler);
|
this.handlers.get(type)!.add(handler);
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { StrictMode } from 'react'
|
// StrictMode removed — causes double WS subscriptions (double useEffect)
|
||||||
|
// import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<App />,
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
)
|
)
|
||||||
@ -5,7 +5,7 @@ export default function CreateProjectPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center">
|
<div className="flex h-[100dvh] items-center justify-center">
|
||||||
<CreateProjectModal
|
<CreateProjectModal
|
||||||
onCreated={(project) => navigate(`/projects/${project.slug}`)}
|
onCreated={(project) => navigate(`/projects/${project.slug}`)}
|
||||||
onClose={() => navigate(-1)}
|
onClose={() => navigate(-1)}
|
||||||
|
|||||||
@ -16,7 +16,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center text-[var(--muted)]">
|
<div className="flex h-[100dvh] items-center justify-center text-[var(--muted)]">
|
||||||
Загрузка...
|
Загрузка...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -27,5 +27,5 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to first project
|
// Redirect to first project
|
||||||
return <Navigate to={`/projects/${projects[0].slug}`} replace />;
|
return <Navigate to={`/projects/${projects[0].id}`} replace />;
|
||||||
}
|
}
|
||||||
@ -30,7 +30,7 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center">
|
<div className="flex h-[100dvh] items-center justify-center">
|
||||||
<form onSubmit={handleSubmit} className="w-full max-w-80 px-4">
|
<form onSubmit={handleSubmit} className="w-full max-w-80 px-4">
|
||||||
<h1 className="text-3xl font-bold mb-1 text-center">Team Board</h1>
|
<h1 className="text-3xl font-bold mb-1 text-center">Team Board</h1>
|
||||||
<p className="text-[var(--muted)] mb-6 text-center text-sm">AI Agent Collaboration Platform</p>
|
<p className="text-[var(--muted)] mb-6 text-center text-sm">AI Agent Collaboration Platform</p>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useSearchParams, Link } from "react-router-dom";
|
||||||
import { getProjects, type Project } from "@/lib/api";
|
import { getProjects, type Project } from "@/lib/api";
|
||||||
|
import { logout } from "@/lib/auth-client";
|
||||||
import Sidebar from "@/components/Sidebar";
|
import Sidebar from "@/components/Sidebar";
|
||||||
import KanbanBoard from "@/components/KanbanBoard";
|
import KanbanBoard from "@/components/KanbanBoard";
|
||||||
import ChatPanel from "@/components/ChatPanel";
|
import ChatPanel from "@/components/ChatPanel";
|
||||||
@ -15,10 +16,18 @@ const TABS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function ProjectPage() {
|
export default function ProjectPage() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { id: projectId } = useParams<{ id: string }>();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState("board");
|
|
||||||
|
const validTabs = ["board", "chat", "files", "settings"];
|
||||||
|
const tabFromUrl = searchParams.get("tab") || "board";
|
||||||
|
const activeTab = validTabs.includes(tabFromUrl) ? tabFromUrl : "board";
|
||||||
|
|
||||||
|
const setActiveTab = (tab: string) => {
|
||||||
|
setSearchParams(tab === "board" ? {} : { tab }, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getProjects()
|
getProjects()
|
||||||
@ -27,23 +36,23 @@ export default function ProjectPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const project = projects.find((p) => p.slug === slug);
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
|
||||||
const handleProjectUpdated = (updated: Project) => {
|
const handleProjectUpdated = (updated: Project) => {
|
||||||
setProjects((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
|
setProjects((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="flex h-screen items-center justify-center text-[var(--muted)]">Загрузка...</div>;
|
return <div className="flex h-[100dvh] items-center justify-center text-[var(--muted)]">Загрузка...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return <div className="flex h-screen items-center justify-center text-[var(--muted)]">Проект не найден</div>;
|
return <div className="flex h-[100dvh] items-center justify-center text-[var(--muted)]">Проект не найден</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen">
|
<div className="flex h-[100dvh]">
|
||||||
<Sidebar projects={projects} activeSlug={slug} />
|
<Sidebar projects={projects} activeId={projectId} />
|
||||||
<main className="flex-1 flex flex-col overflow-hidden">
|
<main className="flex-1 flex flex-col overflow-hidden">
|
||||||
<header className="border-b border-[var(--border)] px-6 py-3 pl-14 md:pl-6">
|
<header className="border-b border-[var(--border)] px-6 py-3 pl-14 md:pl-6">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@ -53,6 +62,22 @@ export default function ProjectPage() {
|
|||||||
<p className="text-sm text-[var(--muted)]">{project.description}</p>
|
<p className="text-sm text-[var(--muted)]">{project.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="text-[var(--muted)] hover:text-[var(--fg)] transition-colors"
|
||||||
|
title="Настройки"
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="text-[var(--muted)] hover:text-[var(--fg)] transition-colors cursor-pointer"
|
||||||
|
title="Выйти"
|
||||||
|
>
|
||||||
|
🚪
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
@ -76,7 +101,7 @@ export default function ProjectPage() {
|
|||||||
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
|
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "chat" && project.chat_id && (
|
{activeTab === "chat" && project.chat_id && (
|
||||||
<ChatPanel chatId={project.chat_id} fullscreen />
|
<ChatPanel chatId={project.chat_id} projectId={project.id} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "chat" && !project.chat_id && (
|
{activeTab === "chat" && !project.chat_id && (
|
||||||
<div className="flex items-center justify-center h-full text-[var(--muted)] text-sm">
|
<div className="flex items-center justify-center h-full text-[var(--muted)] text-sm">
|
||||||
|
|||||||
67
web-client-vite/src/pages/settings/LabelsPage.tsx
Normal file
67
web-client-vite/src/pages/settings/LabelsPage.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { Label } from "@/lib/api";
|
||||||
|
import { getLabels, createLabel, deleteLabel } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function LabelsPage() {
|
||||||
|
const [labels, setLabels] = useState<Label[]>([]);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [newColor, setNewColor] = useState("#6366f1");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getLabels().then(setLabels).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
const label = await createLabel(newName.trim(), newColor);
|
||||||
|
setLabels([...labels, label]);
|
||||||
|
setNewName("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-2xl">
|
||||||
|
<h1 className="text-2xl font-bold mb-2">🏷️ Лейблы</h1>
|
||||||
|
<p className="text-xs text-[var(--muted)] mb-6">Глобальные лейблы для задач и агентов. Используются для авто-назначения.</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{labels.map((label) => (
|
||||||
|
<div key={label.id} className="flex items-center justify-between p-2.5 bg-[var(--bg)] border border-[var(--border)] rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: label.color }} />
|
||||||
|
<span className="text-sm">{label.name}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteLabel(label.id);
|
||||||
|
setLabels(labels.filter(l => l.id !== label.id));
|
||||||
|
}}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300"
|
||||||
|
>Удалить</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{labels.length === 0 && <p className="text-sm text-[var(--muted)]">Нет лейблов. Создайте первый.</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === "Enter" && handleCreate()}
|
||||||
|
placeholder="Новый лейбл..."
|
||||||
|
className="flex-1 px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm outline-none focus:border-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={newColor}
|
||||||
|
onChange={e => setNewColor(e.target.value)}
|
||||||
|
className="w-10 h-10 rounded cursor-pointer border border-[var(--border)]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm disabled:opacity-50"
|
||||||
|
>Создать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { logout } from "@/lib/auth-client";
|
|||||||
|
|
||||||
const MENU = [
|
const MENU = [
|
||||||
{ href: "/settings", label: "Общие", icon: "⚙️" },
|
{ href: "/settings", label: "Общие", icon: "⚙️" },
|
||||||
|
{ href: "/settings/labels", label: "Лейблы", icon: "🏷️" },
|
||||||
{ href: "/settings/agents", label: "Агенты", icon: "🤖" },
|
{ href: "/settings/agents", label: "Агенты", icon: "🤖" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user