326 lines
13 KiB
TypeScript
326 lines
13 KiB
TypeScript
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 {
|
||
project: Project;
|
||
onUpdated: (p: Project) => void;
|
||
}
|
||
|
||
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 (
|
||
<div className="max-w-2xl mx-auto p-6 pb-20 space-y-6">
|
||
<h2 className="text-lg font-bold">Настройки проекта</h2>
|
||
|
||
<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">Описание</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 === "human").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" || m.type === "bridge").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 ${
|
||
agent.type === "bridge" ? "bg-purple-500/20 text-purple-400" : "bg-blue-500/20 text-blue-400"
|
||
}`}>
|
||
{agent.type === "bridge" ? "B" : "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 === "human").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 === "human").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>
|
||
);
|
||
} |