359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
"use client";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
|
|
import { useAuth } from "@/auth/clerk";
|
|
import {
|
|
type ColumnDef,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
useReactTable,
|
|
} from "@tanstack/react-table";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
import { ApiError } from "@/api/mutator";
|
|
import {
|
|
type listBoardsApiV1BoardsGetResponse,
|
|
getListBoardsApiV1BoardsGetQueryKey,
|
|
useDeleteBoardApiV1BoardsBoardIdDelete,
|
|
useListBoardsApiV1BoardsGet,
|
|
} from "@/api/generated/boards/boards";
|
|
import {
|
|
type listBoardGroupsApiV1BoardGroupsGetResponse,
|
|
useListBoardGroupsApiV1BoardGroupsGet,
|
|
} from "@/api/generated/board-groups/board-groups";
|
|
import { formatTimestamp } from "@/lib/formatters";
|
|
import { useOrganizationMembership } from "@/lib/use-organization-membership";
|
|
import type { BoardGroupRead, BoardRead } from "@/api/generated/model";
|
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
|
import { Button, buttonVariants } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { TableEmptyStateRow, TableLoadingRow } from "@/components/ui/table-state";
|
|
|
|
const compactId = (value: string) =>
|
|
value.length > 8 ? `${value.slice(0, 8)}…` : value;
|
|
|
|
export default function BoardsPage() {
|
|
const { isSignedIn } = useAuth();
|
|
const queryClient = useQueryClient();
|
|
|
|
const { isAdmin } = useOrganizationMembership(isSignedIn);
|
|
const [deleteTarget, setDeleteTarget] = useState<BoardRead | null>(null);
|
|
|
|
const boardsKey = getListBoardsApiV1BoardsGetQueryKey();
|
|
const boardsQuery = useListBoardsApiV1BoardsGet<
|
|
listBoardsApiV1BoardsGetResponse,
|
|
ApiError
|
|
>(undefined, {
|
|
query: {
|
|
enabled: Boolean(isSignedIn),
|
|
refetchInterval: 30_000,
|
|
refetchOnMount: "always",
|
|
},
|
|
});
|
|
|
|
const groupsQuery = useListBoardGroupsApiV1BoardGroupsGet<
|
|
listBoardGroupsApiV1BoardGroupsGetResponse,
|
|
ApiError
|
|
>(
|
|
{ limit: 200 },
|
|
{
|
|
query: {
|
|
enabled: Boolean(isSignedIn),
|
|
refetchInterval: 30_000,
|
|
refetchOnMount: "always",
|
|
},
|
|
},
|
|
);
|
|
|
|
const boards = useMemo(
|
|
() =>
|
|
boardsQuery.data?.status === 200
|
|
? (boardsQuery.data.data.items ?? [])
|
|
: [],
|
|
[boardsQuery.data],
|
|
);
|
|
|
|
const groups = useMemo<BoardGroupRead[]>(() => {
|
|
if (groupsQuery.data?.status !== 200) return [];
|
|
return groupsQuery.data.data.items ?? [];
|
|
}, [groupsQuery.data]);
|
|
|
|
const groupById = useMemo(() => {
|
|
const map = new Map<string, BoardGroupRead>();
|
|
for (const group of groups) {
|
|
map.set(group.id, group);
|
|
}
|
|
return map;
|
|
}, [groups]);
|
|
|
|
const deleteMutation = useDeleteBoardApiV1BoardsBoardIdDelete<
|
|
ApiError,
|
|
{ previous?: listBoardsApiV1BoardsGetResponse }
|
|
>(
|
|
{
|
|
mutation: {
|
|
onMutate: async ({ boardId }) => {
|
|
await queryClient.cancelQueries({ queryKey: boardsKey });
|
|
const previous =
|
|
queryClient.getQueryData<listBoardsApiV1BoardsGetResponse>(
|
|
boardsKey,
|
|
);
|
|
if (previous && previous.status === 200) {
|
|
const nextItems = previous.data.items.filter(
|
|
(board) => board.id !== boardId,
|
|
);
|
|
const removedCount = previous.data.items.length - nextItems.length;
|
|
queryClient.setQueryData<listBoardsApiV1BoardsGetResponse>(
|
|
boardsKey,
|
|
{
|
|
...previous,
|
|
data: {
|
|
...previous.data,
|
|
items: nextItems,
|
|
total: Math.max(0, previous.data.total - removedCount),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
return { previous };
|
|
},
|
|
onError: (_error, _board, context) => {
|
|
if (context?.previous) {
|
|
queryClient.setQueryData(boardsKey, context.previous);
|
|
}
|
|
},
|
|
onSuccess: () => {
|
|
setDeleteTarget(null);
|
|
},
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: boardsKey });
|
|
},
|
|
},
|
|
},
|
|
queryClient,
|
|
);
|
|
|
|
const handleDelete = () => {
|
|
if (!deleteTarget) return;
|
|
deleteMutation.mutate({ boardId: deleteTarget.id });
|
|
};
|
|
|
|
const columns = useMemo<ColumnDef<BoardRead>[]>(
|
|
() => [
|
|
{
|
|
accessorKey: "name",
|
|
header: "Board",
|
|
cell: ({ row }) => (
|
|
<Link href={`/boards/${row.original.id}`} className="group block">
|
|
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
|
|
{row.original.name}
|
|
</p>
|
|
</Link>
|
|
),
|
|
},
|
|
{
|
|
id: "group",
|
|
header: "Group",
|
|
cell: ({ row }) => {
|
|
const groupId = row.original.board_group_id;
|
|
if (!groupId) {
|
|
return <span className="text-sm text-slate-400">—</span>;
|
|
}
|
|
const group = groupById.get(groupId);
|
|
const label = group?.name ?? compactId(groupId);
|
|
const title = group?.name ?? groupId;
|
|
return (
|
|
<Link
|
|
href={`/board-groups/${groupId}`}
|
|
className="text-sm font-medium text-slate-700 hover:text-blue-600"
|
|
title={title}
|
|
>
|
|
{label}
|
|
</Link>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "updated_at",
|
|
header: "Updated",
|
|
cell: ({ row }) => (
|
|
<span className="text-sm text-slate-700">
|
|
{formatTimestamp(row.original.updated_at)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "",
|
|
cell: ({ row }) => (
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Link
|
|
href={`/boards/${row.original.id}/edit`}
|
|
className={buttonVariants({ variant: "ghost", size: "sm" })}
|
|
>
|
|
Edit
|
|
</Link>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDeleteTarget(row.original)}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
),
|
|
},
|
|
],
|
|
[groupById],
|
|
);
|
|
|
|
// eslint-disable-next-line react-hooks/incompatible-library
|
|
const table = useReactTable({
|
|
data: boards,
|
|
columns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<DashboardPageLayout
|
|
signedOut={{
|
|
message: "Sign in to view boards.",
|
|
forceRedirectUrl: "/boards",
|
|
signUpForceRedirectUrl: "/boards",
|
|
}}
|
|
title="Boards"
|
|
description={`Manage boards and task workflows. ${boards.length} board${boards.length === 1 ? "" : "s"} total.`}
|
|
headerActions={
|
|
boards.length > 0 && isAdmin ? (
|
|
<Link
|
|
href="/boards/new"
|
|
className={buttonVariants({
|
|
size: "md",
|
|
variant: "primary",
|
|
})}
|
|
>
|
|
Create board
|
|
</Link>
|
|
) : null
|
|
}
|
|
stickyHeader
|
|
>
|
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="sticky top-0 z-10 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<tr key={headerGroup.id}>
|
|
{headerGroup.headers.map((header) => (
|
|
<th
|
|
key={header.id}
|
|
className="px-6 py-3 text-left font-semibold"
|
|
>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext(),
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{boardsQuery.isLoading ? (
|
|
<TableLoadingRow colSpan={columns.length} />
|
|
) : table.getRowModel().rows.length ? (
|
|
table.getRowModel().rows.map((row) => (
|
|
<tr key={row.id} className="transition hover:bg-slate-50">
|
|
{row.getVisibleCells().map((cell) => (
|
|
<td key={cell.id} className="px-6 py-4 align-top">
|
|
{flexRender(
|
|
cell.column.columnDef.cell,
|
|
cell.getContext(),
|
|
)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
) : (
|
|
<TableEmptyStateRow
|
|
colSpan={columns.length}
|
|
icon={
|
|
<svg
|
|
className="h-16 w-16 text-slate-300"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<rect x="3" y="3" width="7" height="7" />
|
|
<rect x="14" y="3" width="7" height="7" />
|
|
<rect x="14" y="14" width="7" height="7" />
|
|
<rect x="3" y="14" width="7" height="7" />
|
|
</svg>
|
|
}
|
|
title="No boards yet"
|
|
description="Create your first board to start routing tasks and monitoring work across agents."
|
|
actionHref="/boards/new"
|
|
actionLabel="Create your first board"
|
|
/>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{boardsQuery.error ? (
|
|
<p className="mt-4 text-sm text-red-500">{boardsQuery.error.message}</p>
|
|
) : null}
|
|
</DashboardPageLayout>
|
|
<Dialog
|
|
open={!!deleteTarget}
|
|
onOpenChange={(nextOpen) => {
|
|
if (!nextOpen) {
|
|
setDeleteTarget(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent aria-label="Delete board">
|
|
<DialogHeader>
|
|
<DialogTitle>Delete board</DialogTitle>
|
|
<DialogDescription>
|
|
This will remove {deleteTarget?.name}. This action cannot be
|
|
undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{deleteMutation.error ? (
|
|
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
|
{deleteMutation.error.message}
|
|
</div>
|
|
) : null}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleDelete} disabled={deleteMutation.isPending}>
|
|
{deleteMutation.isPending ? "Deleting…" : "Delete"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|