- 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
224 lines
6.8 KiB
TypeScript
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>
|
|
);
|
|
}
|