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
This commit is contained in:
Randall Stillwell 2026-04-08 15:49:07 -05:00
parent 3cbba0cf4e
commit 8c84b2a0ac
5 changed files with 734 additions and 76 deletions

View file

@ -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 ? (
<Check className="size-4 text-green-600 dark:text-green-400" />
) : (
<X className="size-4 text-muted-foreground/40" />
);
}
function JsonCell({ value }: { value: unknown }) {
if (!value) return <span className="text-muted-foreground"></span>;
const arr = Array.isArray(value) ? value : [value];
const text = arr.filter(Boolean).join(", ");
return (
<span className="text-muted-foreground max-w-[200px] truncate block" title={text}>
{text || "—"}
</span>
);
}
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<string, string> = {
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<ResponseCard>[
),
enableSorting: false,
enableHiding: false,
meta: { noCopy: true },
},
{
accessorKey: "frontImageUrl",
@ -148,6 +216,7 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
),
enableSorting: false,
enableHiding: true,
meta: { noCopy: true },
},
{
accessorKey: "name",
@ -159,6 +228,7 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
{row.getValue("name") ?? "—"}
</span>
),
meta: { sticky: true },
},
{
accessorKey: "email",
@ -182,6 +252,15 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
</span>
),
},
{
accessorKey: "homePhone",
header: "Home Phone",
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("homePhone") ?? "—"}
</span>
),
},
{
id: "location",
header: "Location",
@ -195,6 +274,53 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
},
enableSorting: false,
},
{
accessorKey: "address",
header: "Address",
cell: ({ row }) => (
<span className="text-muted-foreground max-w-[180px] truncate block" title={row.getValue("address") ?? ""}>
{row.getValue("address") ?? "—"}
</span>
),
},
{
accessorKey: "zip",
header: "Zip",
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("zip") ?? "—"}
</span>
),
},
{
accessorKey: "gender",
header: ({ column }) => (
<SortableHeader column={column}>Gender</SortableHeader>
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("gender") ?? "—"}
</span>
),
},
{
accessorKey: "dateOfBirth",
header: "DOB",
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("dateOfBirth") ?? "—"}
</span>
),
},
{
accessorKey: "maritalStatus",
header: "Marital Status",
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("maritalStatus") ?? "—"}
</span>
),
},
{
accessorKey: "visitType",
header: ({ column }) => (
@ -228,6 +354,112 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
</span>
),
},
{
accessorKey: "prayerRequests",
header: "Prayer Requests",
cell: ({ row }) => (
<span className="text-muted-foreground max-w-[200px] truncate block" title={row.getValue("prayerRequests") ?? ""}>
{row.getValue("prayerRequests") ?? "—"}
</span>
),
},
{
accessorKey: "prayerForTeam",
header: "Prayer Team",
cell: ({ row }) => <BoolCell value={row.original.prayerForTeam} />,
},
{
accessorKey: "prayerConfidential",
header: "Confidential",
cell: ({ row }) => <BoolCell value={row.original.prayerConfidential} />,
},
{
accessorKey: "messageTopics",
id: "messageTopics",
header: "Message Topics",
cell: ({ row }) => <JsonCell value={row.original.messageTopics} />,
enableSorting: false,
},
{
accessorKey: "nextStep",
id: "nextStep",
header: "Next Step",
cell: ({ row }) => <JsonCell value={row.original.nextStep} />,
enableSorting: false,
},
{
accessorKey: "campusPreference",
id: "campusPreference",
header: "Campus",
cell: ({ row }) => <JsonCell value={row.original.campusPreference} />,
enableSorting: false,
},
{
accessorKey: "howHeard",
id: "howHeard",
header: "How Heard",
cell: ({ row }) => <JsonCell value={row.original.howHeard} />,
enableSorting: false,
},
{
accessorKey: "followUp",
header: "Follow-Up",
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("followUp") ?? "—"}
</span>
),
},
{
accessorKey: "notes",
header: "Notes",
cell: ({ row }) => (
<span className="text-muted-foreground max-w-[200px] truncate block" title={row.getValue("notes") ?? ""}>
{row.getValue("notes") ?? "—"}
</span>
),
},
{
accessorKey: "serviceTime",
header: "Service Time",
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("serviceTime") ?? "—"}
</span>
),
},
{
accessorKey: "planningCenter",
header: "Planning Center",
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.getValue("planningCenter") ?? "—"}
</span>
),
},
{
accessorKey: "iSaidYesBookSent",
header: "I Said Yes Book",
cell: ({ row }) => <BoolCell value={row.original.iSaidYesBookSent} />,
},
{
accessorKey: "ftGuestLetterSent",
header: "FT Guest Letter",
cell: ({ row }) => <BoolCell value={row.original.ftGuestLetterSent} />,
},
{
id: "mondayLinked",
header: "Monday.com",
cell: ({ row }) =>
row.original.mondayItemId ? (
<Badge variant="secondary" className="bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 text-[10px]">
Linked
</Badge>
) : (
<span className="text-muted-foreground"></span>
),
enableSorting: false,
},
{
accessorKey: "ocrStatus",
header: ({ column }) => (
@ -334,6 +566,7 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
),
enableSorting: false,
enableHiding: false,
meta: { noCopy: true },
},
];
}

View file

@ -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<ResponseCard[]>([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [columnVisibility, setColumnVisibility] = React.useState<Record<string, boolean>>({});
const [columnVisibility, setColumnVisibility] = React.useState<Record<string, boolean>>(getDefaultColumnVisibility);
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [uploadModalOpen, setUploadModalOpen] = React.useState(false);
const [uploadingRows, setUploadingRows] = React.useState<UploadingFile[]>([]);
@ -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 */}
<SelectionToolbar
selectedIds={selectedIds}
selectedRows={selectedRows}
onMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
onMarkExported={(ids) => handleBulkAction(ids, "exported")}
onReprocess={handleBulkReprocess}

View file

@ -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<TData> = {
data: TData[];
@ -67,6 +132,12 @@ type DataTableProps<TData> = {
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,
@ -103,7 +174,7 @@ export function DataTable<TData extends ResponseCard>({
const [rowSelection, setRowSelection] = React.useState({});
const [internalVisibility, setInternalVisibility] =
React.useState<VisibilityState>({});
React.useState<VisibilityState>(getDefaultColumnVisibility());
const visibility =
controlledVisibility !== undefined ? controlledVisibility : internalVisibility;
@ -116,6 +187,8 @@ export function DataTable<TData extends ResponseCard>({
} | null>(null);
const contextMenuRef = React.useRef<HTMLDivElement>(null);
const [copiedCellId, setCopiedCellId] = React.useState<string | null>(null);
const table = useReactTable({
data,
columns,
@ -166,13 +239,75 @@ export function DataTable<TData extends ResponseCard>({
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<HTMLTableCellElement>, 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<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 });
@ -211,19 +346,27 @@ export function DataTable<TData extends ResponseCard>({
<TableHeader onContextMenu={handleHeaderContextMenu}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
{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>
@ -268,14 +411,48 @@ export function DataTable<TData extends ResponseCard>({
className="cursor-pointer transition-colors hover:bg-muted/30"
onClick={(e) => handleRowClick(e, row.original.id)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="px-4 py-3">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
{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;
return (
<TableCell
key={cell.id}
data-copy-cell={isCopyable ? "true" : undefined}
className={cn(
"px-4 py-3 relative group/cell",
isCopyable && "cursor-cell",
isSticky && "sticky left-0 z-10 bg-background",
isCopied && "ring-2 ring-primary/40 ring-inset"
)}
onClick={
isCopyable
? (e) => handleCellClick(e, cellKey, colId)
: undefined
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
{isCopyable && (
<span className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/cell:opacity-100 transition-opacity pointer-events-none">
{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" />
)}
</span>
)}
</TableCell>
);
})}
</TableRow>
))
) : (
@ -469,25 +646,32 @@ export function DataTable<TData extends ResponseCard>({
<div
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
className="fixed z-50 min-w-[180px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 animate-in fade-in-0 zoom-in-95"
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>
<div className="my-1 h-px bg-border" />
{TOGGLEABLE_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>
{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>
)}

View file

@ -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<string, string> = {};
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 (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
@ -148,6 +180,52 @@ export function Filters({
</div>
<div className="flex shrink-0 items-center gap-2">
{onColumnVisibilityChange && (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="rounded-xl" />
}
>
<Columns3 className="size-4" />
<span className="hidden sm:inline ml-1.5">
Columns
<span className="ml-1 text-muted-foreground">({visibleCount})</span>
</span>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-56 p-0">
<div className="px-3 pt-3 pb-1">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Toggle Columns
</p>
</div>
<div className="max-h-[360px] overflow-y-auto px-1 pb-2">
{COLUMN_GROUPS.map((group) => (
<div key={group.label}>
<div className="px-2 pt-2 pb-0.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
{group.label}
</div>
{group.ids.map((id) => (
<label
key={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={isVisible(id)}
onCheckedChange={(checked) =>
toggleCol(id, checked !== false)
}
/>
{COL_LABELS[id] ?? id}
</label>
))}
</div>
))}
</div>
</PopoverContent>
</Popover>
)}
{onExportCsv && (
<Button
variant="outline"

View file

@ -1,17 +1,29 @@
"use client";
import * as React from "react";
import {
CheckCircle,
ClipboardCopy,
Download,
RefreshCw,
Trash2,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { COPYABLE_FIELDS, type ResponseCard } from "./columns";
interface SelectionToolbarProps {
selectedIds: string[];
selectedRows: ResponseCard[];
onMarkReviewed?: (ids: string[]) => void;
onMarkExported?: (ids: string[]) => void;
onReprocess?: (ids: string[]) => void;
@ -19,8 +31,26 @@ interface SelectionToolbarProps {
onClear: () => void;
}
const DEFAULT_COPY_FIELDS = new Set<string>(["name", "email"]);
function formatFieldValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "boolean") return value ? "Yes" : "No";
if (Array.isArray(value)) return value.filter(Boolean).join(", ");
if (typeof value === "object") {
try {
const arr = Object.values(value).flat().filter(Boolean);
return arr.join(", ");
} catch {
return String(value);
}
}
return String(value);
}
export function SelectionToolbar({
selectedIds,
selectedRows,
onMarkReviewed,
onMarkExported,
onReprocess,
@ -28,6 +58,47 @@ export function SelectionToolbar({
onClear,
}: SelectionToolbarProps) {
const count = selectedIds.length;
const [copyFields, setCopyFields] = React.useState<Set<string>>(
() => new Set(DEFAULT_COPY_FIELDS)
);
const [copyOpen, setCopyOpen] = React.useState(false);
const toggleCopyField = (field: string) => {
setCopyFields((prev) => {
const next = new Set(prev);
if (next.has(field)) next.delete(field);
else next.add(field);
return next;
});
};
const handleCopyToClipboard = async () => {
const fields = COPYABLE_FIELDS.filter((f) => copyFields.has(f.field));
if (fields.length === 0) {
toast.error("Select at least one field to copy");
return;
}
if (fields.length === 1) {
const field = fields[0];
const values = selectedRows
.map((row) => formatFieldValue(row[field.field]))
.filter(Boolean);
await navigator.clipboard.writeText(values.join("\n"));
toast.success(`Copied ${values.length} ${field.label.toLowerCase()} value(s)`);
} else {
const header = fields.map((f) => f.label).join("\t");
const rows = selectedRows.map((row) =>
fields.map((f) => formatFieldValue(row[f.field])).join("\t")
);
await navigator.clipboard.writeText([header, ...rows].join("\n"));
toast.success(
`Copied ${selectedRows.length} row(s) with ${fields.length} field(s)`
);
}
setCopyOpen(false);
};
return (
<div
@ -39,10 +110,16 @@ export function SelectionToolbar({
)}
>
<div className="glass-card flex items-center gap-2 rounded-2xl px-4 py-2.5 shadow-xl sm:gap-3 sm:px-5">
<span className="shrink-0 text-sm font-medium">
<button
onClick={onClear}
className="flex shrink-0 items-center gap-1.5 text-sm font-medium hover:text-primary transition-colors"
>
{count} selected
</span>
<X className="size-3.5" />
</button>
<div className="h-4 w-px bg-border/50" />
<div className="flex items-center gap-1.5">
{onMarkReviewed && (
<Button
@ -77,28 +154,70 @@ export function SelectionToolbar({
<span className="hidden sm:inline ml-1">Reprocess</span>
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="sm"
className="rounded-xl text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => onDelete(selectedIds)}
<Popover open={copyOpen} onOpenChange={setCopyOpen}>
<PopoverTrigger
render={
<Button variant="ghost" size="sm" className="rounded-xl" />
}
>
<Trash2 className="size-4" />
<span className="hidden sm:inline ml-1">Delete</span>
</Button>
)}
<ClipboardCopy className="size-4" />
<span className="hidden sm:inline ml-1">Copy</span>
</PopoverTrigger>
<PopoverContent
side="top"
sideOffset={8}
className="w-64 p-0"
>
<div className="px-3 pt-3 pb-2">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
What to copy
</p>
</div>
<div className="max-h-[280px] overflow-y-auto px-3 space-y-2">
{COPYABLE_FIELDS.map(({ field, label }) => (
<label
key={field}
className="flex items-center justify-between cursor-pointer group"
>
<span className="text-sm group-hover:text-foreground transition-colors">
{label}
</span>
<Switch
size="sm"
checked={copyFields.has(field)}
onCheckedChange={() => toggleCopyField(field)}
/>
</label>
))}
</div>
<div className="p-3 pt-2">
<Button
size="sm"
className="w-full rounded-xl"
onClick={handleCopyToClipboard}
>
<ClipboardCopy className="size-3.5 mr-1.5" />
Copy to clipboard
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<div className="h-4 w-px bg-border/50" />
<Button
variant="ghost"
size="sm"
className="rounded-xl"
onClick={onClear}
>
<X className="size-4" />
<span className="hidden sm:inline ml-1">Clear</span>
</Button>
{onDelete && (
<Button
variant="ghost"
size="sm"
className="rounded-xl text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={() => onDelete(selectedIds)}
>
<Trash2 className="size-4" />
<span className="hidden sm:inline ml-1">Delete</span>
</Button>
)}
</div>
</div>
);