"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" }, ], }, ]; 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", ]; export function getDefaultColumnVisibility(): VisibilityState { const vis: VisibilityState = {}; for (const id of DEFAULT_HIDDEN) { vis[id] = false; } return vis; } type DataTableProps = { data: TData[]; columns: ColumnDef[]; 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({ data, columns, totalCount, page, limit, onPageChange, onLimitChange, columnVisibility: controlledVisibility, onColumnVisibilityChange, sortBy: controlledSortBy, sortOrder: controlledSortOrder, onSortChange, uploadingRows = [], onSelectionChange, }: DataTableProps) { const router = useRouter(); const sorting: SortingState = controlledSortBy && controlledSortOrder ? [{ id: controlledSortBy, desc: controlledSortOrder === "desc" }] : []; const setSorting = React.useCallback( (updater: React.SetStateAction) => { 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(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(null); const [copiedCellId, setCopiedCellId] = React.useState(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)[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 (
{/* Desktop table */}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const colMeta = header.column.columnDef.meta as Record | undefined; const isSticky = header.column.id === "select" || !!colMeta?.sticky; return ( handleHeaderClick(e, header.column.id)} > {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} ); })} ))} {uploadingRows.map((uf) => ( {uf.name}
{uf.progress}%
))} {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( 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 | undefined; const isSticky = colId === "select" || !!colMeta?.sticky; const cellKey = `${row.id}_${colId}`; const isCopied = copiedCellId === cellKey; const cellRef = React.createRef(); return ( {flexRender( cell.column.columnDef.cell, cell.getContext() )} {isCopyable && ( )} ); })} )) ) : ( uploadingRows.length === 0 && ( No results. ) )}
{/* Mobile card layout */}
{uploadingRows.map((uf) => (

{uf.name}

{uf.progress}%
))} {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => (
handleRowClick(e, row.original.id)} >

{row.original.name ?? "Unnamed"}

{row.original.email && (

{row.original.email}

)}
{row.original.ocrStatus}
{row.original.cellPhone && ( {row.original.cellPhone} )} {row.original.visitType && ( {row.original.visitType} )} {row.original.ocrConfidence != null && ( = 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)}% )}
)) ) : ( uploadingRows.length === 0 && (
No results.
) )}
{/* Pagination */}
{totalCount === 0 ? "0 results" : `${start}–${end} of ${totalCount}`}
Rows
{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 ( ); })}
{/* Right-click column visibility context menu */} {contextMenu && (
Toggle Columns
{COLUMN_GROUPS.map((group) => (
{group.label}
{group.columns.map((col) => ( ))} ))}
)}
); }