blob: 493dfae945a54beee7f2c4e0657ec4d46ddda37e [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
"use client";
import Link from "next/link";
import * as React from "react";
import { Avatar, AvatarFallback } from "@/shared/ui/avatar";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { DataTable, type DataTableColumn } from "@/shared/ui/DataTable";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { EmptyState } from "@/shared/ui/EmptyState";
import { ErrorState } from "@/shared/ui/ErrorState";
import { Label } from "@/shared/ui/label";
import { TableSkeleton } from "@/shared/ui/Loading";
import { StatusBadge } from "@/shared/ui/StatusBadge";
import {
useProjectMembers,
useRemoveProjectMember,
useUpdateProjectMember,
} from "../queries";
import type { ProjectMember, ProjectMemberAllocation, ProjectMemberRole } from "../schemas";
const CHIP_ROLE_SUFFIX: Record<ProjectMemberAllocation["role"], string> = {
PI: "P",
CO_PI: "C",
ALLOCATION_MANAGER: "M",
MEMBER: "M",
};
const CHIP_ROLE_TINT: Record<ProjectMemberAllocation["role"], string> = {
PI: "bg-indigo-50 text-indigo-700",
CO_PI: "bg-sky-50 text-sky-700",
ALLOCATION_MANAGER: "bg-slate-100 text-slate-600",
MEMBER: "bg-slate-100 text-slate-600",
};
function AllocationsCell({ allocations }: { allocations: ProjectMemberAllocation[] }) {
if (allocations.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
return (
<div className="flex flex-wrap items-center gap-1">
{allocations.map((a) => (
<Link
key={a.id}
href={`/allocations/${a.id}`}
className={`inline-flex h-6 max-w-[14ch] items-center gap-1 truncate rounded px-1.5 text-xs ${CHIP_ROLE_TINT[a.role]}`}
title={`${a.name} — ${a.role.replace("_", "-")}`}
>
<span className="truncate">{a.name}</span>
<span className="font-mono opacity-70">{CHIP_ROLE_SUFFIX[a.role]}</span>
</Link>
))}
</div>
);
}
export type ProjectMembersTabProps = {
projectId: string;
canManage: boolean;
};
function initialsFrom(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return `${parts[0]?.[0] ?? ""}${parts[1]?.[0] ?? ""}`.toUpperCase();
return name.slice(0, 2).toUpperCase();
}
const ROLE_LABELS: Record<ProjectMemberRole, string> = {
PI: "PI",
CO_PI: "Co-PI",
ALLOCATION_MANAGER: "Allocation Manager",
MEMBER: "Member",
};
export function ProjectMembersTab({ projectId, canManage }: ProjectMembersTabProps) {
const query = useProjectMembers(projectId);
const updateMutation = useUpdateProjectMember(projectId);
const removeMutation = useRemoveProjectMember(projectId);
const [editing, setEditing] = React.useState<ProjectMember | null>(null);
if (query.isLoading) return <TableSkeleton rows={4} columns={4} />;
if (query.error) {
return <ErrorState message={(query.error as Error).message} onRetry={() => query.refetch()} />;
}
const members = query.data ?? [];
if (members.length === 0) {
return (
<EmptyState
heading="No members yet"
description="Add members to grant access to this project's allocations."
/>
);
}
const columns: Array<DataTableColumn<ProjectMember>> = [
{
key: "member",
header: "Member",
cell: (row) => (
<div className="flex items-center gap-3">
<Avatar className="size-8">
<AvatarFallback>{initialsFrom(row.display_name)}</AvatarFallback>
</Avatar>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{row.display_name}</span>
{row.type === "VIRTUAL" && (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide">
Virtual
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">{row.email}</div>
</div>
</div>
),
},
{
key: "role",
header: "Role",
cell: (row) => <span className="text-sm">{ROLE_LABELS[row.role]}</span>,
},
{
key: "allocations",
header: "Allocations",
sortValue: (row) => row.allocations.length,
cell: (row) => <AllocationsCell allocations={row.allocations} />,
},
{
key: "status",
header: "Status",
cell: (row) => (
<StatusBadge
variant={row.status === "ACTIVE" ? "active" : row.status === "INACTIVE" ? "inactive" : "deleted"}
label={row.status}
/>
),
},
{
key: "actions",
header: "",
align: "right",
interactive: true,
// PI and CO_PI come from the upstream allocation; never edited or removed here.
cell: (row) =>
canManage && row.role !== "PI" && row.role !== "CO_PI" ? (
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(row)}
aria-label={`Edit ${row.display_name}`}
>
Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => removeMutation.mutate(row.id)}
aria-label={`Remove ${row.display_name}`}
disabled={removeMutation.isPending}
>
Remove
</Button>
</div>
) : null,
},
];
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
{members.length} member{members.length === 1 ? "" : "s"}
</p>
<DataTable columns={columns} rows={members} rowKey={(row) => row.id} />
<EditMemberDialog
member={editing}
onClose={() => setEditing(null)}
onSubmit={(payload) => {
if (!editing) return;
updateMutation.mutate(
{ memberId: editing.id, payload },
{ onSuccess: () => setEditing(null) },
);
}}
isPending={updateMutation.isPending}
/>
</div>
);
}
type EditMemberDialogProps = {
member: ProjectMember | null;
onClose: () => void;
onSubmit: (payload: { role?: ProjectMemberRole }) => void;
isPending: boolean;
};
function EditMemberDialog({ member, onClose, onSubmit, isPending }: EditMemberDialogProps) {
const [role, setRole] = React.useState<ProjectMemberRole>("MEMBER");
React.useEffect(() => {
if (member) setRole(member.role === "ALLOCATION_MANAGER" ? "ALLOCATION_MANAGER" : "MEMBER");
}, [member]);
return (
<Dialog open={Boolean(member)} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit member</DialogTitle>
<DialogDescription>{member?.display_name}</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
onSubmit({ role });
}}
>
<div className="space-y-2">
<Label htmlFor="member-role">Role</Label>
<select
id="member-role"
value={role}
onChange={(e) => setRole(e.target.value as ProjectMemberRole)}
className="h-9 w-full rounded-md border bg-background px-3 text-sm"
>
<option value="ALLOCATION_MANAGER">Allocation Manager</option>
<option value="MEMBER">Member</option>
</select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}