echos-ocr/src/components/cards/selection-toolbar.tsx
Randall Stillwell 8c84b2a0ac 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
2026-04-08 15:49:07 -05:00

224 lines
6.8 KiB
TypeScript

"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;
onDelete?: (ids: string[]) => void;
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,
onDelete,
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
className={cn(
"fixed bottom-6 left-1/2 z-50 -translate-x-1/2 transition-all duration-300 ease-out",
count > 0
? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-4 opacity-0"
)}
>
<div className="glass-card flex items-center gap-2 rounded-2xl px-4 py-2.5 shadow-xl sm:gap-3 sm:px-5">
<button
onClick={onClear}
className="flex shrink-0 items-center gap-1.5 text-sm font-medium hover:text-primary transition-colors"
>
{count} selected
<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
variant="ghost"
size="sm"
className="rounded-xl"
onClick={() => onMarkReviewed(selectedIds)}
>
<CheckCircle className="size-4" />
<span className="hidden sm:inline ml-1">Reviewed</span>
</Button>
)}
{onMarkExported && (
<Button
variant="ghost"
size="sm"
className="rounded-xl"
onClick={() => onMarkExported(selectedIds)}
>
<Download className="size-4" />
<span className="hidden sm:inline ml-1">Export</span>
</Button>
)}
{onReprocess && (
<Button
variant="ghost"
size="sm"
className="rounded-xl"
onClick={() => onReprocess(selectedIds)}
>
<RefreshCw className="size-4" />
<span className="hidden sm:inline ml-1">Reprocess</span>
</Button>
)}
<Popover open={copyOpen} onOpenChange={setCopyOpen}>
<PopoverTrigger
render={
<Button variant="ghost" size="sm" className="rounded-xl" />
}
>
<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" />
{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>
);
}