web-client-vite/src/components/ProjectSettings.tsx
Markov f3eeed5eb9 Labels UI + task labels on kanban + API functions
- Labels management in ProjectSettings (CRUD, color picker)
- Task labels displayed as colored pills on kanban cards
- API: getLabels, createLabel, deleteLabel, addTaskLabel, removeTaskLabel
- Task links API functions
2026-02-27 23:46:59 +01:00

349 lines
14 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, Label } from "@/lib/api";
import {
updateProject,
deleteProject,
getProjectMembers,
addProjectMember,
removeProjectMember,
getMembers,
getLabels,
createLabel,
deleteLabel,
} 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 [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 state
const [labels, setLabels] = useState<Label[]>([]);
const [newLabelName, setNewLabelName] = useState("");
const [newLabelColor, setNewLabelColor] = useState("#6366f1");
// 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);
const projectLabels = await getLabels(project.id);
setLabels(projectLabels);
} 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-xl mx-auto p-6 pb-20 space-y-6 overflow-y-auto h-full">
<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>
<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 */}
<div className="pt-6 border-t border-[var(--border)]">
<div className="text-sm font-medium mb-3">Лейблы</div>
<div className="space-y-2 mb-3">
{labels.map((label) => (
<div key={label.id} className="flex items-center justify-between p-2 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>
))}
</div>
<div className="flex gap-2">
<input
value={newLabelName}
onChange={e => setNewLabelName(e.target.value)}
placeholder="Новый лейбл..."
className="flex-1 px-3 py-1.5 bg-[var(--bg)] border border-[var(--border)] rounded-lg text-sm outline-none"
/>
<input
type="color"
value={newLabelColor}
onChange={e => setNewLabelColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer"
/>
<button
onClick={async () => {
if (!newLabelName.trim()) return;
const label = await createLabel(project.id, newLabelName.trim(), newLabelColor);
setLabels([...labels, label]);
setNewLabelName("");
}}
className="px-3 py-1.5 bg-[var(--accent)] text-white rounded-lg text-sm"
>+</button>
</div>
</div>
{/* 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>
);
}