web-client-vite/src/components/ProjectSettings.tsx

326 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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