From 8c84b2a0ac352b9bf7bb999174b7783d5173d7b1 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Wed, 8 Apr 2026 15:49:07 -0500 Subject: [PATCH] Enhanced table view with cell copy, column toggles, and ClickUp-style toolbar - Expand ResponseCard type to include all 30+ Prisma fields - Add 18 new toggleable columns (Personal, Survey, Prayer, Workflow groups) - ClickUp-style Copy popover in selection toolbar with field toggle switches - Click-to-copy on individual cells with visual feedback - Ctrl/Cmd+click column headers to copy column values - Columns dropdown in filter bar for column visibility management - Sticky select/name columns when scrolling horizontally - Keyboard shortcuts: Ctrl+C to copy, Escape to clear selection - Grouped context menu for column visibility (Core, Personal, Survey, Prayer, Workflow) Made-with: Cursor --- src/components/cards/columns.tsx | 233 +++++++++++++++++ src/components/cards/dashboard-content.tsx | 50 +++- src/components/cards/data-table.tsx | 286 +++++++++++++++++---- src/components/cards/filters.tsx | 80 +++++- src/components/cards/selection-toolbar.tsx | 161 ++++++++++-- 5 files changed, 734 insertions(+), 76 deletions(-) diff --git a/src/components/cards/columns.tsx b/src/components/cards/columns.tsx index df838f5..6935d1c 100644 --- a/src/components/cards/columns.tsx +++ b/src/components/cards/columns.tsx @@ -10,6 +10,8 @@ import { ArrowUp, ArrowDown, RefreshCw, + Check, + X, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -54,18 +56,61 @@ function SortableHeader({ ); } +function BoolCell({ value }: { value: boolean }) { + return value ? ( + + ) : ( + + ); +} + +function JsonCell({ value }: { value: unknown }) { + if (!value) return ; + const arr = Array.isArray(value) ? value : [value]; + const text = arr.filter(Boolean).join(", "); + return ( + + {text || "—"} + + ); +} + export type ResponseCard = { id: string; createdAt: string; name: string | null; email: string | null; cellPhone: string | null; + homePhone: string | null; + address: string | null; + aptNumber: string | null; city: string | null; state: string | null; + zip: string | null; gender: string | null; + dateOfBirth: string | null; + maritalStatus: string | null; + maritalStatusOther: string | null; visitType: string | null; + prayerRequests: string | null; + prayerForTeam: boolean; + prayerConfidential: boolean; + messageTopics: unknown; + messageTopicsOther: string | null; + nextStep: unknown; attendanceDuration: string | null; + campusPreference: unknown; + campusPreferenceOther: string | null; + howHeard: unknown; + howHeardOther: string | null; serviceAttended: string | null; + followUp: string | null; + notes: string | null; + serviceTime: string | null; + planningCenter: string | null; + iSaidYesBookSent: boolean; + ftGuestLetterSent: boolean; + mondayItemId: string | null; ocrStatus: string; reviewStatus: string; ocrConfidence: number | null; @@ -73,6 +118,28 @@ export type ResponseCard = { backImageUrl: string | null; }; +export const COPYABLE_FIELDS: { field: keyof ResponseCard; label: string }[] = [ + { field: "name", label: "Name" }, + { field: "email", label: "Email" }, + { field: "cellPhone", label: "Phone" }, + { field: "homePhone", label: "Home Phone" }, + { field: "address", label: "Address" }, + { field: "city", label: "City" }, + { field: "state", label: "State" }, + { field: "zip", label: "Zip" }, + { field: "gender", label: "Gender" }, + { field: "dateOfBirth", label: "Date of Birth" }, + { field: "maritalStatus", label: "Marital Status" }, + { field: "visitType", label: "Visit Type" }, + { field: "attendanceDuration", label: "Attendance" }, + { field: "serviceAttended", label: "Service" }, + { field: "prayerRequests", label: "Prayer Requests" }, + { field: "followUp", label: "Follow-Up" }, + { field: "notes", label: "Notes" }, + { field: "reviewStatus", label: "Review Status" }, + { field: "ocrStatus", label: "OCR Status" }, +]; + const ocrStatusVariant: Record = { pending: "bg-muted text-muted-foreground", processing: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300", @@ -135,6 +202,7 @@ export function createColumns(actions?: ColumnActions): ColumnDef[ ), enableSorting: false, enableHiding: false, + meta: { noCopy: true }, }, { accessorKey: "frontImageUrl", @@ -148,6 +216,7 @@ export function createColumns(actions?: ColumnActions): ColumnDef[ ), enableSorting: false, enableHiding: true, + meta: { noCopy: true }, }, { accessorKey: "name", @@ -159,6 +228,7 @@ export function createColumns(actions?: ColumnActions): ColumnDef[ {row.getValue("name") ?? "—"} ), + meta: { sticky: true }, }, { accessorKey: "email", @@ -182,6 +252,15 @@ export function createColumns(actions?: ColumnActions): ColumnDef[ ), }, + { + accessorKey: "homePhone", + header: "Home Phone", + cell: ({ row }) => ( + + {row.getValue("homePhone") ?? "—"} + + ), + }, { id: "location", header: "Location", @@ -195,6 +274,53 @@ export function createColumns(actions?: ColumnActions): ColumnDef[ }, enableSorting: false, }, + { + accessorKey: "address", + header: "Address", + cell: ({ row }) => ( + + {row.getValue("address") ?? "—"} + + ), + }, + { + accessorKey: "zip", + header: "Zip", + cell: ({ row }) => ( + + {row.getValue("zip") ?? "—"} + + ), + }, + { + accessorKey: "gender", + header: ({ column }) => ( + Gender + ), + cell: ({ row }) => ( + + {row.getValue("gender") ?? "—"} + + ), + }, + { + accessorKey: "dateOfBirth", + header: "DOB", + cell: ({ row }) => ( + + {row.getValue("dateOfBirth") ?? "—"} + + ), + }, + { + accessorKey: "maritalStatus", + header: "Marital Status", + cell: ({ row }) => ( + + {row.getValue("maritalStatus") ?? "—"} + + ), + }, { accessorKey: "visitType", header: ({ column }) => ( @@ -228,6 +354,112 @@ export function createColumns(actions?: ColumnActions): ColumnDef[ ), }, + { + accessorKey: "prayerRequests", + header: "Prayer Requests", + cell: ({ row }) => ( + + {row.getValue("prayerRequests") ?? "—"} + + ), + }, + { + accessorKey: "prayerForTeam", + header: "Prayer Team", + cell: ({ row }) => , + }, + { + accessorKey: "prayerConfidential", + header: "Confidential", + cell: ({ row }) => , + }, + { + accessorKey: "messageTopics", + id: "messageTopics", + header: "Message Topics", + cell: ({ row }) => , + enableSorting: false, + }, + { + accessorKey: "nextStep", + id: "nextStep", + header: "Next Step", + cell: ({ row }) => , + enableSorting: false, + }, + { + accessorKey: "campusPreference", + id: "campusPreference", + header: "Campus", + cell: ({ row }) => , + enableSorting: false, + }, + { + accessorKey: "howHeard", + id: "howHeard", + header: "How Heard", + cell: ({ row }) => , + enableSorting: false, + }, + { + accessorKey: "followUp", + header: "Follow-Up", + cell: ({ row }) => ( + + {row.getValue("followUp") ?? "—"} + + ), + }, + { + accessorKey: "notes", + header: "Notes", + cell: ({ row }) => ( + + {row.getValue("notes") ?? "—"} + + ), + }, + { + accessorKey: "serviceTime", + header: "Service Time", + cell: ({ row }) => ( + + {row.getValue("serviceTime") ?? "—"} + + ), + }, + { + accessorKey: "planningCenter", + header: "Planning Center", + cell: ({ row }) => ( + + {row.getValue("planningCenter") ?? "—"} + + ), + }, + { + accessorKey: "iSaidYesBookSent", + header: "I Said Yes Book", + cell: ({ row }) => , + }, + { + accessorKey: "ftGuestLetterSent", + header: "FT Guest Letter", + cell: ({ row }) => , + }, + { + id: "mondayLinked", + header: "Monday.com", + cell: ({ row }) => + row.original.mondayItemId ? ( + + Linked + + ) : ( + + ), + enableSorting: false, + }, { accessorKey: "ocrStatus", header: ({ column }) => ( @@ -334,6 +566,7 @@ export function createColumns(actions?: ColumnActions): ColumnDef[ ), enableSorting: false, enableHiding: false, + meta: { noCopy: true }, }, ]; } diff --git a/src/components/cards/dashboard-content.tsx b/src/components/cards/dashboard-content.tsx index d806ec2..e5ed0d4 100644 --- a/src/components/cards/dashboard-content.tsx +++ b/src/components/cards/dashboard-content.tsx @@ -6,10 +6,10 @@ import { toast } from "sonner"; import { StatCards, type StatFilter } from "./stat-cards"; import { Filters } from "./filters"; -import { DataTable } from "./data-table"; +import { DataTable, getDefaultColumnVisibility } from "./data-table"; import { SelectionToolbar } from "./selection-toolbar"; import { UploadModal, type UploadingFile } from "./upload-modal"; -import { createColumns, type ResponseCard } from "./columns"; +import { createColumns, COPYABLE_FIELDS, type ResponseCard } from "./columns"; const VISIT_TYPE_OPTIONS = [ "First/Second Time Guest", @@ -38,7 +38,7 @@ export function DashboardContent() { const [data, setData] = React.useState([]); const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(true); - const [columnVisibility, setColumnVisibility] = React.useState>({}); + const [columnVisibility, setColumnVisibility] = React.useState>(getDefaultColumnVisibility); const [selectedIds, setSelectedIds] = React.useState([]); const [uploadModalOpen, setUploadModalOpen] = React.useState(false); const [uploadingRows, setUploadingRows] = React.useState([]); @@ -261,6 +261,47 @@ export function DashboardContent() { ); }; + const selectedRows = React.useMemo( + () => { + if (selectedIds.length === 0) return []; + const idSet = new Set(selectedIds); + return data.filter((c) => idSet.has(c.id)); + }, + [data, selectedIds] + ); + + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + + if (e.key === "Escape" && selectedIds.length > 0) { + setSelectedIds([]); + return; + } + + if ((e.metaKey || e.ctrlKey) && e.key === "c" && selectedIds.length > 0) { + e.preventDefault(); + const fields = COPYABLE_FIELDS.filter((f) => + f.field === "name" || f.field === "email" + ); + const values = selectedRows.map((row) => + fields.map((f) => { + const v = row[f.field]; + return v != null ? String(v) : ""; + }).join("\t") + ); + const header = fields.map((f) => f.label).join("\t"); + navigator.clipboard.writeText([header, ...values].join("\n")).then(() => { + toast.success(`Copied ${selectedRows.length} row(s) to clipboard`); + }); + return; + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [selectedIds, selectedRows]); + React.useEffect(() => { const handleOpenUpload = () => setUploadModalOpen(true); window.addEventListener("open-upload-modal", handleOpenUpload); @@ -339,6 +380,8 @@ export function DashboardContent() { serviceAttendedOptions={SERVICE_OPTIONS} onExportCsv={handleExportCsv} onUploadClick={openUpload} + columnVisibility={columnVisibility} + onColumnVisibilityChange={setColumnVisibility} /> {/* Data table */} @@ -364,6 +407,7 @@ export function DashboardContent() { {/* Floating selection toolbar */} handleBulkAction(ids, "reviewed")} onMarkExported={(ids) => handleBulkAction(ids, "exported")} onReprocess={handleBulkReprocess} diff --git a/src/components/cards/data-table.tsx b/src/components/cards/data-table.tsx index bf5f1af..a7f9fe2 100644 --- a/src/components/cards/data-table.tsx +++ b/src/components/cards/data-table.tsx @@ -17,7 +17,9 @@ import { ChevronLeft, ChevronRight, Loader2, + Copy, } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -34,19 +36,82 @@ import { cn } from "@/lib/utils"; import type { ResponseCard } from "./columns"; import type { UploadingFile } from "./upload-modal"; -const TOGGLEABLE_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: "attendanceDuration", label: "Attendance" }, - { id: "serviceAttended", label: "Service" }, - { id: "ocrStatus", label: "OCR Status" }, - { id: "reviewStatus", label: "Review" }, - { id: "ocrConfidence", label: "Confidence" }, -] as const; +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: "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", "mondayLinked", +]; + +export function getDefaultColumnVisibility(): VisibilityState { + const vis: VisibilityState = {}; + for (const id of DEFAULT_HIDDEN) { + vis[id] = false; + } + return vis; +} type DataTableProps = { data: TData[]; @@ -67,6 +132,12 @@ type DataTableProps = { 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, @@ -103,7 +174,7 @@ export function DataTable({ const [rowSelection, setRowSelection] = React.useState({}); const [internalVisibility, setInternalVisibility] = - React.useState({}); + React.useState(getDefaultColumnVisibility()); const visibility = controlledVisibility !== undefined ? controlledVisibility : internalVisibility; @@ -116,6 +187,8 @@ export function DataTable({ } | null>(null); const contextMenuRef = React.useRef(null); + const [copiedCellId, setCopiedCellId] = React.useState(null); + const table = useReactTable({ data, columns, @@ -166,13 +239,75 @@ export function DataTable({ target.closest("button") || target.closest("input") || target.closest('[data-slot="checkbox"]') || - target.closest('[data-slot="dropdown-menu"]') + target.closest('[data-slot="dropdown-menu"]') || + target.closest("[data-copy-cell]") ) { return; } router.push(`/cards/${rowId}`); }; + const handleCellClick = React.useCallback( + async (e: React.MouseEvent, cellId: string, columnId: string) => { + if (NON_COPYABLE_COLUMNS.has(columnId)) return; + + if ( + (e.target as HTMLElement).closest("button") || + (e.target as HTMLElement).closest("input") || + (e.target as HTMLElement).closest('[data-slot="checkbox"]') + ) { + return; + } + + e.stopPropagation(); + const text = getCellText(e.currentTarget); + 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 }); @@ -211,19 +346,27 @@ export function DataTable({ {table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} + {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() + )} + + ); + })} ))} @@ -268,14 +411,48 @@ export function DataTable({ className="cursor-pointer transition-colors hover:bg-muted/30" onClick={(e) => handleRowClick(e, row.original.id)} > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} + {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; + + return ( + handleCellClick(e, cellKey, colId) + : undefined + } + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + {isCopyable && ( + + {isCopied ? ( + + Copied + + ) : ( + + )} + + )} + + ); + })} )) ) : ( @@ -469,25 +646,32 @@ export function DataTable({
Toggle Columns
-
- {TOGGLEABLE_COLUMNS.map((col) => ( - + {COLUMN_GROUPS.map((group) => ( + +
+
+ {group.label} +
+ {group.columns.map((col) => ( + + ))} + ))}
)} diff --git a/src/components/cards/filters.tsx b/src/components/cards/filters.tsx index bb45573..b431689 100644 --- a/src/components/cards/filters.tsx +++ b/src/components/cards/filters.tsx @@ -2,7 +2,8 @@ import * as React from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; -import { Search, Download, Upload } from "lucide-react"; +import { Search, Download, Upload, Columns3 } from "lucide-react"; +import type { VisibilityState } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -13,6 +14,26 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ALL_TOGGLEABLE_COLUMNS } from "./data-table"; + +const COLUMN_GROUPS: { label: string; ids: string[] }[] = [ + { label: "Core", ids: ["thumbnail", "name", "email", "cellPhone", "location", "visitType", "ocrStatus", "reviewStatus", "ocrConfidence"] }, + { label: "Personal", ids: ["homePhone", "gender", "dateOfBirth", "maritalStatus", "address", "zip"] }, + { label: "Survey", ids: ["attendanceDuration", "serviceAttended", "messageTopics", "nextStep", "campusPreference", "howHeard"] }, + { label: "Prayer", ids: ["prayerRequests", "prayerForTeam", "prayerConfidential"] }, + { label: "Workflow", ids: ["followUp", "notes", "serviceTime", "planningCenter", "iSaidYesBookSent", "ftGuestLetterSent", "mondayLinked"] }, +]; + +const COL_LABELS: Record = {}; +for (const col of ALL_TOGGLEABLE_COLUMNS) { + COL_LABELS[col.id] = col.label; +} export type FiltersProps = { search?: string; @@ -24,6 +45,8 @@ export type FiltersProps = { serviceAttendedOptions?: string[]; onExportCsv?: () => void; onUploadClick?: () => void; + columnVisibility?: VisibilityState; + onColumnVisibilityChange?: (visibility: VisibilityState) => void; }; export function Filters({ @@ -36,6 +59,8 @@ export function Filters({ serviceAttendedOptions = [], onExportCsv, onUploadClick, + columnVisibility = {}, + onColumnVisibilityChange, }: FiltersProps) { const router = useRouter(); const pathname = usePathname(); @@ -69,6 +94,13 @@ export function Filters({ updateParams({ search: v || undefined }); }; + const isVisible = (id: string) => columnVisibility[id] !== false; + const toggleCol = (id: string, checked: boolean) => { + onColumnVisibilityChange?.({ ...columnVisibility, [id]: checked }); + }; + + const visibleCount = ALL_TOGGLEABLE_COLUMNS.filter((c) => isVisible(c.id)).length; + return (
@@ -148,6 +180,52 @@ export function Filters({
+ {onColumnVisibilityChange && ( + + + } + > + + + Columns + ({visibleCount}) + + + +
+

+ Toggle Columns +

+
+
+ {COLUMN_GROUPS.map((group) => ( +
+
+ {group.label} +
+ {group.ids.map((id) => ( + + ))} +
+ ))} +
+
+
+ )} + {onExportCsv && ( +
+
{onMarkReviewed && ( )} - {onDelete && ( - - )} + + Copy + + +
+

+ What to copy +

+
+
+ {COPYABLE_FIELDS.map(({ field, label }) => ( + + ))} +
+
+ +
+
+
+
- + + {onDelete && ( + + )}
);