Files
ftb-project-management/apps/web/components/project/ProjectMemberPanel.tsx

106 lines
4.6 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { ShieldCheck, Trash2, UserPlus } from 'lucide-react';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
type ProjectRole = 'owner' | 'admin' | 'member' | 'viewer';
interface ProjectMemberRow {
id: string;
projectId: string;
userId: string;
role: ProjectRole;
user?: { id: string; name: string; email?: string };
}
const ROLE_LABEL: Record<ProjectRole, string> = {
owner: 'Owner',
admin: 'Admin',
member: 'Member',
viewer: 'Viewer',
};
export function ProjectMemberPanel({ projectId }: { projectId: string }) {
const user = useAuthStore((s) => s.user);
const { members, fetchMembers } = useMemberStore();
const [rows, setRows] = useState<ProjectMemberRow[]>([]);
const [selectedUserId, setSelectedUserId] = useState('');
const [selectedRole, setSelectedRole] = useState<ProjectRole>('member');
const actorId = user?.id ?? '';
const reload = async () => {
if (!projectId) return;
const data = await api.get<ProjectMemberRow[]>(`/projects/${projectId}/members`);
setRows(data);
};
useEffect(() => {
void fetchMembers().catch(() => {});
void reload().catch(() => {});
}, [projectId]);
const add = async () => {
if (!actorId || !selectedUserId) return;
await api.post(`/projects/${projectId}/members`, { actorId, userId: selectedUserId, role: selectedRole });
setSelectedUserId('');
await reload();
};
const updateRole = async (member: ProjectMemberRow, role: ProjectRole) => {
if (!actorId) return;
await api.patch(`/projects/${projectId}/members/${member.userId}/role`, { actorId, role });
await reload();
};
const remove = async (member: ProjectMemberRow) => {
if (!actorId) return;
await api.deleteWithBody(`/projects/${projectId}/members/${member.userId}`, { actorId });
await reload();
};
const existingUserIds = new Set(rows.map((row) => row.userId));
const candidates = members.filter((member) => !existingUserIds.has(member.id));
return (
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="mb-3 flex items-center gap-1.5 text-[12px] font-semibold text-[var(--ink)]">
<ShieldCheck className="h-4 w-4 text-[var(--accent)]" />
</div>
<div className="mb-3 grid grid-cols-[1fr_108px_auto] gap-2">
<select value={selectedUserId} onChange={(event) => setSelectedUserId(event.target.value)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px]">
<option value=""></option>
{candidates.map((member) => <option key={member.id} value={member.id}>{member.name}</option>)}
</select>
<select value={selectedRole} onChange={(event) => setSelectedRole(event.target.value as ProjectRole)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px]">
{Object.entries(ROLE_LABEL).map(([role, label]) => <option key={role} value={role}>{label}</option>)}
</select>
<button onClick={add} disabled={!selectedUserId} className="inline-flex h-8 items-center gap-1 rounded-md bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
<UserPlus className="h-3 w-3" />
</button>
</div>
<div className="divide-y divide-[var(--line)] rounded-md border border-[var(--line)]">
{rows.length === 0 ? (
<div className="px-3 py-6 text-center text-[12px] text-[var(--ink-muted)]"></div>
) : rows.map((row) => (
<div key={row.id} className="grid grid-cols-[1fr_112px_32px] items-center gap-2 px-3 py-2">
<div className="min-w-0">
<div className="truncate text-[12px] font-medium text-[var(--ink)]">{row.user?.name ?? row.userId}</div>
<div className="truncate text-[10px] text-[var(--ink-muted)]">{row.user?.email ?? row.userId}</div>
</div>
<select value={row.role} onChange={(event) => updateRole(row, event.target.value as ProjectRole).catch(() => {})} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[11px]">
{Object.entries(ROLE_LABEL).map(([role, label]) => <option key={role} value={role}>{label}</option>)}
</select>
<button onClick={() => remove(row).catch(() => {})} className="rounded p-1.5 text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600" title="移除成员">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
);
}