- Add User model synced from Authentik headers with admin/reviewer/viewer roles - Add assignment fields (assignedToId, assignedById, reviewedById, etc.) to ResponseCard - Add userId tracking to ActivityLog and Notification models - Create auth.ts with getOrCreateUser() and role mapping from Authentik groups - Create /api/users endpoint and /api/cards/assign batch assignment endpoint - Gate card mutations behind role checks (viewers read-only, reviewers edit assigned only) - Gate Monday.com push behind reviewStatus=reviewed instead of ocr_complete - Add "My Cards" stat card, Assigned To filter, and assignment columns to table - Add Assign button with user picker to batch selection toolbar (admin only) - Update card detail: assignment banner, Mark Complete button, prev/next nav, reassign - Make all field components accept readOnly prop for role-based editing - Gate settings page behind admin role - Add userId to stats API for per-user card counts - Expose dbUser and role through UserProfileProvider context Made-with: Cursor
675 lines
24 KiB
TypeScript
675 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
type ColumnDef,
|
||
type SortingState,
|
||
type VisibilityState,
|
||
flexRender,
|
||
getCoreRowModel,
|
||
getFilteredRowModel,
|
||
getPaginationRowModel,
|
||
getSortedRowModel,
|
||
useReactTable,
|
||
} from "@tanstack/react-table";
|
||
import {
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Loader2,
|
||
Copy,
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { cn } from "@/lib/utils";
|
||
import type { ResponseCard } from "./columns";
|
||
import type { UploadingFile } from "./upload-modal";
|
||
|
||
const COLUMN_GROUPS: { label: string; columns: { id: string; label: string }[] }[] = [
|
||
{
|
||
label: "Core",
|
||
columns: [
|
||
{ id: "thumbnail", label: "Thumbnail" },
|
||
{ id: "name", label: "Name" },
|
||
{ id: "email", label: "Email" },
|
||
{ id: "cellPhone", label: "Phone" },
|
||
{ id: "location", label: "Location" },
|
||
{ id: "visitType", label: "Visit Type" },
|
||
{ id: "ocrStatus", label: "OCR Status" },
|
||
{ id: "reviewStatus", label: "Review" },
|
||
{ id: "ocrConfidence", label: "Confidence" },
|
||
],
|
||
},
|
||
{
|
||
label: "Personal",
|
||
columns: [
|
||
{ id: "homePhone", label: "Home Phone" },
|
||
{ id: "gender", label: "Gender" },
|
||
{ id: "dateOfBirth", label: "Date of Birth" },
|
||
{ id: "maritalStatus", label: "Marital Status" },
|
||
{ id: "address", label: "Address" },
|
||
{ id: "zip", label: "Zip" },
|
||
],
|
||
},
|
||
{
|
||
label: "Survey",
|
||
columns: [
|
||
{ id: "attendanceDuration", label: "Attendance" },
|
||
{ id: "serviceAttended", label: "Service" },
|
||
{ id: "messageTopics", label: "Message Topics" },
|
||
{ id: "nextStep", label: "Next Step" },
|
||
{ id: "campusPreference", label: "Campus" },
|
||
{ id: "howHeard", label: "How Heard" },
|
||
],
|
||
},
|
||
{
|
||
label: "Prayer",
|
||
columns: [
|
||
{ id: "prayerRequests", label: "Prayer Requests" },
|
||
{ id: "prayerForTeam", label: "Prayer Team" },
|
||
{ id: "prayerConfidential", label: "Confidential" },
|
||
],
|
||
},
|
||
{
|
||
label: "Workflow",
|
||
columns: [
|
||
{ id: "followUp", label: "Follow-Up" },
|
||
{ id: "notes", label: "Notes" },
|
||
{ id: "serviceTime", label: "Service Time" },
|
||
{ id: "planningCenter", label: "Planning Center" },
|
||
{ id: "iSaidYesBookSent", label: "I Said Yes Book" },
|
||
{ id: "ftGuestLetterSent", label: "FT Guest Letter" },
|
||
{ id: "firstTimeGuestDate", label: "FT Guest Date" },
|
||
{ id: "salvationDate", label: "Salvation Date" },
|
||
{ id: "mondayLinked", label: "Monday.com" },
|
||
{ id: "assignedTo", label: "Assigned To" },
|
||
{ id: "reviewedBy", label: "Reviewed By" },
|
||
],
|
||
},
|
||
];
|
||
|
||
export const ALL_TOGGLEABLE_COLUMNS = COLUMN_GROUPS.flatMap((g) => g.columns);
|
||
|
||
const DEFAULT_HIDDEN: string[] = [
|
||
"homePhone", "address", "zip", "gender", "dateOfBirth", "maritalStatus",
|
||
"prayerRequests", "prayerForTeam", "prayerConfidential",
|
||
"messageTopics", "nextStep", "campusPreference", "howHeard",
|
||
"followUp", "notes", "serviceTime", "planningCenter",
|
||
"iSaidYesBookSent", "ftGuestLetterSent", "firstTimeGuestDate", "salvationDate", "mondayLinked",
|
||
"assignedTo", "reviewedBy",
|
||
];
|
||
|
||
export function getDefaultColumnVisibility(): VisibilityState {
|
||
const vis: VisibilityState = {};
|
||
for (const id of DEFAULT_HIDDEN) {
|
||
vis[id] = false;
|
||
}
|
||
return vis;
|
||
}
|
||
|
||
type DataTableProps<TData> = {
|
||
data: TData[];
|
||
columns: ColumnDef<TData, unknown>[];
|
||
totalCount: number;
|
||
page: number;
|
||
limit: number;
|
||
onPageChange?: (page: number) => void;
|
||
onLimitChange?: (limit: number) => void;
|
||
columnVisibility?: VisibilityState;
|
||
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
|
||
sortBy?: string;
|
||
sortOrder?: "asc" | "desc";
|
||
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
|
||
uploadingRows?: UploadingFile[];
|
||
onSelectionChange?: (selectedIds: string[]) => void;
|
||
};
|
||
|
||
const PAGE_SIZES = [10, 20, 50, 100];
|
||
|
||
const NON_COPYABLE_COLUMNS = new Set(["select", "thumbnail", "actions"]);
|
||
|
||
function getCellText(el: HTMLElement): string {
|
||
return (el.textContent ?? "").trim();
|
||
}
|
||
|
||
export function DataTable<TData extends ResponseCard>({
|
||
data,
|
||
columns,
|
||
totalCount,
|
||
page,
|
||
limit,
|
||
onPageChange,
|
||
onLimitChange,
|
||
columnVisibility: controlledVisibility,
|
||
onColumnVisibilityChange,
|
||
sortBy: controlledSortBy,
|
||
sortOrder: controlledSortOrder,
|
||
onSortChange,
|
||
uploadingRows = [],
|
||
onSelectionChange,
|
||
}: DataTableProps<TData>) {
|
||
const router = useRouter();
|
||
|
||
const sorting: SortingState =
|
||
controlledSortBy && controlledSortOrder
|
||
? [{ id: controlledSortBy, desc: controlledSortOrder === "desc" }]
|
||
: [];
|
||
const setSorting = React.useCallback(
|
||
(updater: React.SetStateAction<SortingState>) => {
|
||
const next =
|
||
typeof updater === "function" ? updater(sorting) : updater;
|
||
const first = next[0];
|
||
if (first && onSortChange) {
|
||
onSortChange(first.id, first.desc ? "desc" : "asc");
|
||
}
|
||
},
|
||
[sorting, onSortChange]
|
||
);
|
||
|
||
const [rowSelection, setRowSelection] = React.useState({});
|
||
const [internalVisibility, setInternalVisibility] =
|
||
React.useState<VisibilityState>(getDefaultColumnVisibility());
|
||
|
||
const visibility =
|
||
controlledVisibility !== undefined ? controlledVisibility : internalVisibility;
|
||
const setVisibility =
|
||
onColumnVisibilityChange ?? setInternalVisibility;
|
||
|
||
const [contextMenu, setContextMenu] = React.useState<{
|
||
x: number;
|
||
y: number;
|
||
} | null>(null);
|
||
const contextMenuRef = React.useRef<HTMLDivElement>(null);
|
||
|
||
const [copiedCellId, setCopiedCellId] = React.useState<string | null>(null);
|
||
|
||
const table = useReactTable({
|
||
data,
|
||
columns,
|
||
getCoreRowModel: getCoreRowModel(),
|
||
getPaginationRowModel: getPaginationRowModel(),
|
||
getSortedRowModel: getSortedRowModel(),
|
||
getFilteredRowModel: getFilteredRowModel(),
|
||
onSortingChange: setSorting,
|
||
onRowSelectionChange: setRowSelection,
|
||
onColumnVisibilityChange: (updater) => {
|
||
const next =
|
||
typeof updater === "function" ? updater(visibility) : updater;
|
||
setVisibility(next);
|
||
},
|
||
state: {
|
||
sorting,
|
||
rowSelection,
|
||
columnVisibility: visibility,
|
||
pagination: {
|
||
pageIndex: page - 1,
|
||
pageSize: limit,
|
||
},
|
||
},
|
||
manualPagination: true,
|
||
manualSorting: !!onSortChange,
|
||
pageCount: Math.ceil(totalCount / limit) || 1,
|
||
});
|
||
|
||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||
const selectedIds = React.useMemo(
|
||
() => selectedRows.map((r) => r.original.id),
|
||
[selectedRows]
|
||
);
|
||
|
||
React.useEffect(() => {
|
||
onSelectionChange?.(selectedIds);
|
||
}, [selectedIds, onSelectionChange]);
|
||
|
||
const totalPages = Math.ceil(totalCount / limit);
|
||
const canPrev = page > 1;
|
||
const canNext = page < totalPages;
|
||
const start = (page - 1) * limit + 1;
|
||
const end = Math.min(page * limit, totalCount);
|
||
|
||
const handleRowClick = (e: React.MouseEvent, rowId: string) => {
|
||
const target = e.target as HTMLElement;
|
||
if (
|
||
target.closest("button") ||
|
||
target.closest("input") ||
|
||
target.closest('[data-slot="checkbox"]') ||
|
||
target.closest('[data-slot="dropdown-menu"]')
|
||
) {
|
||
return;
|
||
}
|
||
router.push(`/cards/${rowId}`);
|
||
};
|
||
|
||
const handleCopyClick = React.useCallback(
|
||
async (e: React.MouseEvent, cellElement: HTMLTableCellElement, cellId: string) => {
|
||
e.stopPropagation();
|
||
const text = getCellText(cellElement);
|
||
if (!text || text === "—") return;
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
setCopiedCellId(cellId);
|
||
setTimeout(() => setCopiedCellId(null), 1200);
|
||
} catch {
|
||
toast.error("Failed to copy");
|
||
}
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handleHeaderClick = React.useCallback(
|
||
async (e: React.MouseEvent, columnId: string) => {
|
||
if (!(e.metaKey || e.ctrlKey)) return;
|
||
if (NON_COPYABLE_COLUMNS.has(columnId)) return;
|
||
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const rows = selectedRows.length > 0
|
||
? selectedRows.map((r) => r.original)
|
||
: data;
|
||
|
||
const values = rows
|
||
.map((row) => {
|
||
const val = (row as Record<string, unknown>)[columnId];
|
||
if (val == null) return "";
|
||
if (Array.isArray(val)) return val.filter(Boolean).join(", ");
|
||
return String(val);
|
||
})
|
||
.filter(Boolean);
|
||
|
||
if (values.length === 0) {
|
||
toast("No values to copy");
|
||
return;
|
||
}
|
||
|
||
await navigator.clipboard.writeText(values.join("\n"));
|
||
const colLabel =
|
||
ALL_TOGGLEABLE_COLUMNS.find((c) => c.id === columnId)?.label ?? columnId;
|
||
toast.success(`Copied ${values.length} ${colLabel} value(s)`);
|
||
},
|
||
[data, selectedRows]
|
||
);
|
||
|
||
const handleHeaderContextMenu = (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||
};
|
||
|
||
React.useEffect(() => {
|
||
if (!contextMenu) return;
|
||
const close = (e: MouseEvent) => {
|
||
if (
|
||
contextMenuRef.current &&
|
||
contextMenuRef.current.contains(e.target as Node)
|
||
) {
|
||
return;
|
||
}
|
||
setContextMenu(null);
|
||
};
|
||
window.addEventListener("click", close);
|
||
window.addEventListener("contextmenu", close);
|
||
return () => {
|
||
window.removeEventListener("click", close);
|
||
window.removeEventListener("contextmenu", close);
|
||
};
|
||
}, [contextMenu]);
|
||
|
||
const isColumnVisible = (id: string) => visibility[id] !== false;
|
||
const toggleColumn = (id: string, visible: boolean) => {
|
||
setVisibility({ ...visibility, [id]: visible });
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Desktop table */}
|
||
<div className="glass-card overflow-hidden rounded-2xl hidden md:block">
|
||
<div className="overflow-x-auto">
|
||
<Table>
|
||
<TableHeader onContextMenu={handleHeaderContextMenu}>
|
||
{table.getHeaderGroups().map((headerGroup) => (
|
||
<TableRow key={headerGroup.id}>
|
||
{headerGroup.headers.map((header) => {
|
||
const colMeta = header.column.columnDef.meta as Record<string, unknown> | undefined;
|
||
const isSticky = header.column.id === "select" || !!colMeta?.sticky;
|
||
return (
|
||
<TableHead
|
||
key={header.id}
|
||
className={cn(
|
||
"px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground",
|
||
isSticky && "sticky left-0 z-20 bg-background"
|
||
)}
|
||
onClick={(e) => handleHeaderClick(e, header.column.id)}
|
||
>
|
||
{header.isPlaceholder
|
||
? null
|
||
: flexRender(
|
||
header.column.columnDef.header,
|
||
header.getContext()
|
||
)}
|
||
</TableHead>
|
||
);
|
||
})}
|
||
</TableRow>
|
||
))}
|
||
</TableHeader>
|
||
<TableBody>
|
||
{uploadingRows.map((uf) => (
|
||
<TableRow key={uf.id} className="opacity-70">
|
||
<TableCell className="px-4 py-3" />
|
||
<TableCell className="px-4 py-3" />
|
||
<TableCell className="px-4 py-3">
|
||
<span className="font-medium text-muted-foreground">
|
||
{uf.name}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell className="px-4 py-3" colSpan={6} />
|
||
<TableCell className="px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<Loader2 className="size-3.5 animate-spin text-primary" />
|
||
<div className="flex items-center gap-2">
|
||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
|
||
<div
|
||
className="h-full rounded-full bg-primary transition-all"
|
||
style={{ width: `${uf.progress}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-xs text-muted-foreground">
|
||
{uf.progress}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="px-4 py-3" />
|
||
<TableCell className="px-4 py-3" />
|
||
<TableCell className="px-4 py-3" />
|
||
</TableRow>
|
||
))}
|
||
|
||
{table.getRowModel().rows?.length ? (
|
||
table.getRowModel().rows.map((row) => (
|
||
<TableRow
|
||
key={row.id}
|
||
data-state={row.getIsSelected() && "selected"}
|
||
className="cursor-pointer transition-colors hover:bg-muted/30"
|
||
onClick={(e) => handleRowClick(e, row.original.id)}
|
||
>
|
||
{row.getVisibleCells().map((cell) => {
|
||
const colId = cell.column.id;
|
||
const isCopyable = !NON_COPYABLE_COLUMNS.has(colId);
|
||
const colMeta = cell.column.columnDef.meta as Record<string, unknown> | undefined;
|
||
const isSticky = colId === "select" || !!colMeta?.sticky;
|
||
const cellKey = `${row.id}_${colId}`;
|
||
const isCopied = copiedCellId === cellKey;
|
||
const cellRef = React.createRef<HTMLTableCellElement>();
|
||
|
||
return (
|
||
<TableCell
|
||
key={cell.id}
|
||
ref={cellRef}
|
||
className={cn(
|
||
"px-4 py-3 relative group/cell",
|
||
isSticky && "sticky left-0 z-10 bg-background",
|
||
isCopied && "ring-2 ring-primary/40 ring-inset"
|
||
)}
|
||
>
|
||
{flexRender(
|
||
cell.column.columnDef.cell,
|
||
cell.getContext()
|
||
)}
|
||
{isCopyable && (
|
||
<button
|
||
type="button"
|
||
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/cell:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
|
||
onClick={(e) => {
|
||
if (cellRef.current) handleCopyClick(e, cellRef.current, cellKey);
|
||
}}
|
||
>
|
||
{isCopied ? (
|
||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-primary/10 text-primary">
|
||
Copied
|
||
</Badge>
|
||
) : (
|
||
<Copy className="size-3 text-muted-foreground/50" />
|
||
)}
|
||
</button>
|
||
)}
|
||
</TableCell>
|
||
);
|
||
})}
|
||
</TableRow>
|
||
))
|
||
) : (
|
||
uploadingRows.length === 0 && (
|
||
<TableRow>
|
||
<TableCell
|
||
colSpan={columns.length}
|
||
className="h-24 text-center text-muted-foreground"
|
||
>
|
||
No results.
|
||
</TableCell>
|
||
</TableRow>
|
||
)
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile card layout */}
|
||
<div className="space-y-3 md:hidden">
|
||
{uploadingRows.map((uf) => (
|
||
<div
|
||
key={uf.id}
|
||
className="glass-card rounded-2xl p-4 opacity-70"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<Loader2 className="size-4 animate-spin text-primary" />
|
||
<div className="flex-1 min-w-0">
|
||
<p className="truncate text-sm font-medium">{uf.name}</p>
|
||
<div className="mt-1.5 flex items-center gap-2">
|
||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||
<div
|
||
className="h-full rounded-full bg-primary transition-all"
|
||
style={{ width: `${uf.progress}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-xs text-muted-foreground">
|
||
{uf.progress}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{table.getRowModel().rows?.length ? (
|
||
table.getRowModel().rows.map((row) => (
|
||
<div
|
||
key={row.id}
|
||
className={cn(
|
||
"glass-card cursor-pointer rounded-2xl p-4 transition-all hover:shadow-lg active:scale-[0.99]",
|
||
row.getIsSelected() && "ring-2 ring-primary/30"
|
||
)}
|
||
onClick={(e) => handleRowClick(e, row.original.id)}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1 min-w-0">
|
||
<p className="truncate text-sm font-semibold">
|
||
{row.original.name ?? "Unnamed"}
|
||
</p>
|
||
{row.original.email && (
|
||
<p className="truncate text-xs text-muted-foreground mt-0.5">
|
||
{row.original.email}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<Badge
|
||
variant="secondary"
|
||
className={cn(
|
||
"capitalize text-[10px] px-1.5 py-0.5",
|
||
row.original.ocrStatus === "complete"
|
||
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
|
||
: row.original.ocrStatus === "error"
|
||
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
|
||
: "bg-muted text-muted-foreground"
|
||
)}
|
||
>
|
||
{row.original.ocrStatus}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||
{row.original.cellPhone && (
|
||
<span>{row.original.cellPhone}</span>
|
||
)}
|
||
{row.original.visitType && (
|
||
<span>{row.original.visitType}</span>
|
||
)}
|
||
{row.original.ocrConfidence != null && (
|
||
<span
|
||
className={cn(
|
||
"font-medium",
|
||
row.original.ocrConfidence >= 75
|
||
? "text-green-600 dark:text-green-400"
|
||
: row.original.ocrConfidence >= 50
|
||
? "text-amber-600 dark:text-amber-400"
|
||
: "text-red-600 dark:text-red-400"
|
||
)}
|
||
>
|
||
{Math.round(row.original.ocrConfidence)}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
uploadingRows.length === 0 && (
|
||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||
No results.
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||
<span>
|
||
{totalCount === 0
|
||
? "0 results"
|
||
: `${start}–${end} of ${totalCount}`}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs">Rows</span>
|
||
<select
|
||
value={limit}
|
||
onChange={(e) => onLimitChange?.(Number(e.target.value))}
|
||
className="h-8 rounded-xl border border-input bg-transparent px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||
>
|
||
{PAGE_SIZES.map((size) => (
|
||
<option key={size} value={size}>
|
||
{size}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="rounded-xl"
|
||
onClick={() => onPageChange?.(page - 1)}
|
||
disabled={!canPrev}
|
||
>
|
||
<ChevronLeft className="size-4" />
|
||
<span className="hidden sm:inline ml-1">Prev</span>
|
||
</Button>
|
||
<div className="flex items-center gap-1">
|
||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||
let p: number;
|
||
if (totalPages <= 5) {
|
||
p = i + 1;
|
||
} else if (page <= 3) {
|
||
p = i + 1;
|
||
} else if (page >= totalPages - 2) {
|
||
p = totalPages - 4 + i;
|
||
} else {
|
||
p = page - 2 + i;
|
||
}
|
||
return (
|
||
<Button
|
||
key={p}
|
||
variant={p === page ? "default" : "ghost"}
|
||
size="sm"
|
||
className="size-8 rounded-xl p-0"
|
||
onClick={() => onPageChange?.(p)}
|
||
>
|
||
{p}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="rounded-xl"
|
||
onClick={() => onPageChange?.(page + 1)}
|
||
disabled={!canNext}
|
||
>
|
||
<span className="hidden sm:inline mr-1">Next</span>
|
||
<ChevronRight className="size-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right-click column visibility context menu */}
|
||
{contextMenu && (
|
||
<div
|
||
ref={contextMenuRef}
|
||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||
className="fixed z-50 min-w-[200px] max-h-[70vh] overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 animate-in fade-in-0 zoom-in-95"
|
||
>
|
||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||
Toggle Columns
|
||
</div>
|
||
{COLUMN_GROUPS.map((group) => (
|
||
<React.Fragment key={group.label}>
|
||
<div className="my-1 h-px bg-border" />
|
||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
|
||
{group.label}
|
||
</div>
|
||
{group.columns.map((col) => (
|
||
<label
|
||
key={col.id}
|
||
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
||
>
|
||
<Checkbox
|
||
checked={isColumnVisible(col.id)}
|
||
onCheckedChange={(checked) =>
|
||
toggleColumn(col.id, checked !== false)
|
||
}
|
||
/>
|
||
{col.label}
|
||
</label>
|
||
))}
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|