feat: add cell formatters and tables for boards, agents, and member invites
This commit is contained in:
106
frontend/src/components/organization/BoardAccessTable.tsx
Normal file
106
frontend/src/components/organization/BoardAccessTable.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { type BoardRead } from "@/api/generated/model";
|
||||
import { linkifyCell } from "@/components/tables/cell-formatters";
|
||||
import { DataTable } from "@/components/tables/DataTable";
|
||||
|
||||
type BoardAccessState = Record<string, { read: boolean; write: boolean }>;
|
||||
|
||||
type BoardAccessTableProps = {
|
||||
boards: BoardRead[];
|
||||
access: BoardAccessState;
|
||||
onToggleRead: (boardId: string) => void;
|
||||
onToggleWrite: (boardId: string) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function BoardAccessTable({
|
||||
boards,
|
||||
access,
|
||||
onToggleRead,
|
||||
onToggleWrite,
|
||||
disabled = false,
|
||||
}: BoardAccessTableProps) {
|
||||
const columns = useMemo<ColumnDef<BoardRead>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Board",
|
||||
cell: ({ row }) =>
|
||||
linkifyCell({
|
||||
href: `/boards/${row.original.id}`,
|
||||
label: row.original.name,
|
||||
subtitle: row.original.slug,
|
||||
subtitleClassName: "mt-1 text-xs text-slate-500",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "read",
|
||||
header: "Read",
|
||||
cell: ({ row }) => {
|
||||
const entry = access[row.original.id] ?? {
|
||||
read: false,
|
||||
write: false,
|
||||
};
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={entry.read}
|
||||
onChange={() => onToggleRead(row.original.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "write",
|
||||
header: "Write",
|
||||
cell: ({ row }) => {
|
||||
const entry = access[row.original.id] ?? {
|
||||
read: false,
|
||||
write: false,
|
||||
};
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={entry.write}
|
||||
onChange={() => onToggleWrite(row.original.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[access, disabled, onToggleRead, onToggleWrite],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: boards,
|
||||
columns,
|
||||
enableSorting: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
rowClassName="border-t border-slate-200 hover:bg-slate-50"
|
||||
headerClassName="bg-slate-50 text-[11px] uppercase tracking-wide text-slate-500"
|
||||
headerCellClassName="px-4 py-2 font-medium"
|
||||
cellClassName="px-4 py-3"
|
||||
/>
|
||||
);
|
||||
}
|
||||
254
frontend/src/components/organization/MembersInvitesTable.tsx
Normal file
254
frontend/src/components/organization/MembersInvitesTable.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Copy } from "lucide-react";
|
||||
|
||||
import type {
|
||||
OrganizationInviteRead,
|
||||
OrganizationMemberRead,
|
||||
} from "@/api/generated/model";
|
||||
import { DataTable } from "@/components/tables/DataTable";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatTimestamp } from "@/lib/formatters";
|
||||
|
||||
type MemberInviteRow =
|
||||
| { kind: "member"; member: OrganizationMemberRead }
|
||||
| { kind: "invite"; invite: OrganizationInviteRead };
|
||||
|
||||
type MembersInvitesTableProps = {
|
||||
members: OrganizationMemberRead[];
|
||||
invites: OrganizationInviteRead[];
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
copiedInviteId: string | null;
|
||||
onManageAccess: (memberId: string) => void;
|
||||
onCopyInvite: (invite: OrganizationInviteRead) => void;
|
||||
onRevokeInvite: (inviteId: string) => void;
|
||||
isRevoking: boolean;
|
||||
};
|
||||
|
||||
const roleBadgeVariant = (role: string) => {
|
||||
if (role === "admin" || role === "owner") return "accent" as const;
|
||||
return "outline" as const;
|
||||
};
|
||||
|
||||
const initialsFrom = (value?: string | null) => {
|
||||
if (!value) return "?";
|
||||
const parts = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
};
|
||||
|
||||
const summarizeAccess = (allRead: boolean, allWrite: boolean) => {
|
||||
if (allRead || allWrite) {
|
||||
if (allRead && allWrite) return "All boards: read + write";
|
||||
if (allWrite) return "All boards: write";
|
||||
return "All boards: read";
|
||||
}
|
||||
return "Selected boards";
|
||||
};
|
||||
|
||||
const memberDisplay = (member: OrganizationMemberRead) => {
|
||||
const primary =
|
||||
member.user?.name ||
|
||||
member.user?.preferred_name ||
|
||||
member.user?.email ||
|
||||
member.user_id;
|
||||
const secondary = member.user?.email ?? "No email on file";
|
||||
return {
|
||||
primary,
|
||||
secondary,
|
||||
initials: initialsFrom(primary),
|
||||
};
|
||||
};
|
||||
|
||||
export function MembersInvitesTable({
|
||||
members,
|
||||
invites,
|
||||
isLoading,
|
||||
isAdmin,
|
||||
copiedInviteId,
|
||||
onManageAccess,
|
||||
onCopyInvite,
|
||||
onRevokeInvite,
|
||||
isRevoking,
|
||||
}: MembersInvitesTableProps) {
|
||||
const rows = useMemo<MemberInviteRow[]>(
|
||||
() => [
|
||||
...members.map((member) => ({ kind: "member" as const, member })),
|
||||
...invites.map((invite) => ({ kind: "invite" as const, invite })),
|
||||
],
|
||||
[invites, members],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnDef<MemberInviteRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "member",
|
||||
header: "Member",
|
||||
cell: ({ row }) => {
|
||||
if (row.original.kind === "member") {
|
||||
const display = memberDisplay(row.original.member);
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-500 text-xs font-semibold text-white">
|
||||
{display.initials}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{display.primary}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{display.secondary}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-slate-200 text-xs font-semibold text-slate-600">
|
||||
{initialsFrom(row.original.invite.invited_email)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{row.original.invite.invited_email}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Invited {formatTimestamp(row.original.invite.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
if (row.original.kind === "member") {
|
||||
return (
|
||||
<Badge variant={roleBadgeVariant(row.original.member.role)}>
|
||||
{row.original.member.role}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="warning">Pending</Badge>
|
||||
<Badge variant={roleBadgeVariant(row.original.invite.role)}>
|
||||
{row.original.invite.role}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "access",
|
||||
header: "Access",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-slate-600">
|
||||
{row.original.kind === "member"
|
||||
? summarizeAccess(
|
||||
row.original.member.all_boards_read,
|
||||
row.original.member.all_boards_write,
|
||||
)
|
||||
: summarizeAccess(
|
||||
row.original.invite.all_boards_read,
|
||||
row.original.invite.all_boards_write,
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
if (row.original.kind === "member") {
|
||||
const member = row.original.member;
|
||||
if (!isAdmin) {
|
||||
return <span className="text-xs text-slate-400">Admin only</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onManageAccess(member.id)}
|
||||
>
|
||||
Manage access
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const invite = row.original.invite;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onCopyInvite(invite)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
{copiedInviteId === invite.id ? "Copied" : "Copy link"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRevokeInvite(invite.id)}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
copiedInviteId,
|
||||
isAdmin,
|
||||
isRevoking,
|
||||
onCopyInvite,
|
||||
onManageAccess,
|
||||
onRevokeInvite,
|
||||
],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
enableSorting: false,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
loadingLabel="Loading members..."
|
||||
emptyMessage="No members or invites yet."
|
||||
headerClassName="bg-slate-50 text-[11px] uppercase tracking-wide text-slate-500"
|
||||
headerCellClassName="px-5 py-3 text-left font-medium"
|
||||
cellClassName="px-5 py-4"
|
||||
rowClassName={(row) =>
|
||||
row.original.kind === "invite"
|
||||
? "border-t border-slate-200 bg-slate-50/60"
|
||||
: "border-t border-slate-200 hover:bg-slate-50"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user