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:
parent
3cbba0cf4e
commit
8c84b2a0ac
5 changed files with 734 additions and 76 deletions
|
|
@ -10,6 +10,8 @@ import {
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 = {
|
export type ResponseCard = {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
cellPhone: string | null;
|
cellPhone: string | null;
|
||||||
|
homePhone: string | null;
|
||||||
|
address: string | null;
|
||||||
|
aptNumber: string | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
state: string | null;
|
state: string | null;
|
||||||
|
zip: string | null;
|
||||||
gender: string | null;
|
gender: string | null;
|
||||||
|
dateOfBirth: string | null;
|
||||||
|
maritalStatus: string | null;
|
||||||
|
maritalStatusOther: string | null;
|
||||||
visitType: string | null;
|
visitType: string | null;
|
||||||
|
prayerRequests: string | null;
|
||||||
|
prayerForTeam: boolean;
|
||||||
|
prayerConfidential: boolean;
|
||||||
|
messageTopics: unknown;
|
||||||
|
messageTopicsOther: string | null;
|
||||||
|
nextStep: unknown;
|
||||||
attendanceDuration: string | null;
|
attendanceDuration: string | null;
|
||||||
|
campusPreference: unknown;
|
||||||
|
campusPreferenceOther: string | null;
|
||||||
|
howHeard: unknown;
|
||||||
|
howHeardOther: string | null;
|
||||||
serviceAttended: 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;
|
ocrStatus: string;
|
||||||
reviewStatus: string;
|
reviewStatus: string;
|
||||||
ocrConfidence: number | null;
|
ocrConfidence: number | null;
|
||||||
|
|
@ -73,6 +118,28 @@ export type ResponseCard = {
|
||||||
backImageUrl: string | null;
|
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> = {
|
const ocrStatusVariant: Record<string, string> = {
|
||||||
pending: "bg-muted text-muted-foreground",
|
pending: "bg-muted text-muted-foreground",
|
||||||
processing: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
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,
|
enableSorting: false,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
|
meta: { noCopy: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "frontImageUrl",
|
accessorKey: "frontImageUrl",
|
||||||
|
|
@ -148,6 +216,7 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: true,
|
enableHiding: true,
|
||||||
|
meta: { noCopy: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
|
|
@ -159,6 +228,7 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
|
||||||
{row.getValue("name") ?? "—"}
|
{row.getValue("name") ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
|
meta: { sticky: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "email",
|
accessorKey: "email",
|
||||||
|
|
@ -182,6 +252,15 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "homePhone",
|
||||||
|
header: "Home Phone",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{row.getValue("homePhone") ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "location",
|
id: "location",
|
||||||
header: "Location",
|
header: "Location",
|
||||||
|
|
@ -195,6 +274,53 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
|
||||||
},
|
},
|
||||||
enableSorting: false,
|
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",
|
accessorKey: "visitType",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
|
|
@ -228,6 +354,112 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
|
||||||
</span>
|
</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",
|
accessorKey: "ocrStatus",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
|
|
@ -334,6 +566,7 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
|
meta: { noCopy: true },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ import { toast } from "sonner";
|
||||||
|
|
||||||
import { StatCards, type StatFilter } from "./stat-cards";
|
import { StatCards, type StatFilter } from "./stat-cards";
|
||||||
import { Filters } from "./filters";
|
import { Filters } from "./filters";
|
||||||
import { DataTable } from "./data-table";
|
import { DataTable, getDefaultColumnVisibility } from "./data-table";
|
||||||
import { SelectionToolbar } from "./selection-toolbar";
|
import { SelectionToolbar } from "./selection-toolbar";
|
||||||
import { UploadModal, type UploadingFile } from "./upload-modal";
|
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 = [
|
const VISIT_TYPE_OPTIONS = [
|
||||||
"First/Second Time Guest",
|
"First/Second Time Guest",
|
||||||
|
|
@ -38,7 +38,7 @@ export function DashboardContent() {
|
||||||
const [data, setData] = React.useState<ResponseCard[]>([]);
|
const [data, setData] = React.useState<ResponseCard[]>([]);
|
||||||
const [total, setTotal] = React.useState(0);
|
const [total, setTotal] = React.useState(0);
|
||||||
const [loading, setLoading] = React.useState(true);
|
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 [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||||
const [uploadModalOpen, setUploadModalOpen] = React.useState(false);
|
const [uploadModalOpen, setUploadModalOpen] = React.useState(false);
|
||||||
const [uploadingRows, setUploadingRows] = React.useState<UploadingFile[]>([]);
|
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(() => {
|
React.useEffect(() => {
|
||||||
const handleOpenUpload = () => setUploadModalOpen(true);
|
const handleOpenUpload = () => setUploadModalOpen(true);
|
||||||
window.addEventListener("open-upload-modal", handleOpenUpload);
|
window.addEventListener("open-upload-modal", handleOpenUpload);
|
||||||
|
|
@ -339,6 +380,8 @@ export function DashboardContent() {
|
||||||
serviceAttendedOptions={SERVICE_OPTIONS}
|
serviceAttendedOptions={SERVICE_OPTIONS}
|
||||||
onExportCsv={handleExportCsv}
|
onExportCsv={handleExportCsv}
|
||||||
onUploadClick={openUpload}
|
onUploadClick={openUpload}
|
||||||
|
columnVisibility={columnVisibility}
|
||||||
|
onColumnVisibilityChange={setColumnVisibility}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Data table */}
|
{/* Data table */}
|
||||||
|
|
@ -364,6 +407,7 @@ export function DashboardContent() {
|
||||||
{/* Floating selection toolbar */}
|
{/* Floating selection toolbar */}
|
||||||
<SelectionToolbar
|
<SelectionToolbar
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
|
selectedRows={selectedRows}
|
||||||
onMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
|
onMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
|
||||||
onMarkExported={(ids) => handleBulkAction(ids, "exported")}
|
onMarkExported={(ids) => handleBulkAction(ids, "exported")}
|
||||||
onReprocess={handleBulkReprocess}
|
onReprocess={handleBulkReprocess}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Copy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -34,19 +36,82 @@ import { cn } from "@/lib/utils";
|
||||||
import type { ResponseCard } from "./columns";
|
import type { ResponseCard } from "./columns";
|
||||||
import type { UploadingFile } from "./upload-modal";
|
import type { UploadingFile } from "./upload-modal";
|
||||||
|
|
||||||
const TOGGLEABLE_COLUMNS = [
|
const COLUMN_GROUPS: { label: string; columns: { id: string; label: string }[] }[] = [
|
||||||
|
{
|
||||||
|
label: "Core",
|
||||||
|
columns: [
|
||||||
{ id: "thumbnail", label: "Thumbnail" },
|
{ id: "thumbnail", label: "Thumbnail" },
|
||||||
{ id: "name", label: "Name" },
|
{ id: "name", label: "Name" },
|
||||||
{ id: "email", label: "Email" },
|
{ id: "email", label: "Email" },
|
||||||
{ id: "cellPhone", label: "Phone" },
|
{ id: "cellPhone", label: "Phone" },
|
||||||
{ id: "location", label: "Location" },
|
{ id: "location", label: "Location" },
|
||||||
{ id: "visitType", label: "Visit Type" },
|
{ id: "visitType", label: "Visit Type" },
|
||||||
{ id: "attendanceDuration", label: "Attendance" },
|
|
||||||
{ id: "serviceAttended", label: "Service" },
|
|
||||||
{ id: "ocrStatus", label: "OCR Status" },
|
{ id: "ocrStatus", label: "OCR Status" },
|
||||||
{ id: "reviewStatus", label: "Review" },
|
{ id: "reviewStatus", label: "Review" },
|
||||||
{ id: "ocrConfidence", label: "Confidence" },
|
{ id: "ocrConfidence", label: "Confidence" },
|
||||||
] as const;
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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> = {
|
type DataTableProps<TData> = {
|
||||||
data: TData[];
|
data: TData[];
|
||||||
|
|
@ -67,6 +132,12 @@ type DataTableProps<TData> = {
|
||||||
|
|
||||||
const PAGE_SIZES = [10, 20, 50, 100];
|
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>({
|
export function DataTable<TData extends ResponseCard>({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
|
|
@ -103,7 +174,7 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = React.useState({});
|
const [rowSelection, setRowSelection] = React.useState({});
|
||||||
const [internalVisibility, setInternalVisibility] =
|
const [internalVisibility, setInternalVisibility] =
|
||||||
React.useState<VisibilityState>({});
|
React.useState<VisibilityState>(getDefaultColumnVisibility());
|
||||||
|
|
||||||
const visibility =
|
const visibility =
|
||||||
controlledVisibility !== undefined ? controlledVisibility : internalVisibility;
|
controlledVisibility !== undefined ? controlledVisibility : internalVisibility;
|
||||||
|
|
@ -116,6 +187,8 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const contextMenuRef = React.useRef<HTMLDivElement>(null);
|
const contextMenuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [copiedCellId, setCopiedCellId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
|
|
@ -166,13 +239,75 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
target.closest("button") ||
|
target.closest("button") ||
|
||||||
target.closest("input") ||
|
target.closest("input") ||
|
||||||
target.closest('[data-slot="checkbox"]') ||
|
target.closest('[data-slot="checkbox"]') ||
|
||||||
target.closest('[data-slot="dropdown-menu"]')
|
target.closest('[data-slot="dropdown-menu"]') ||
|
||||||
|
target.closest("[data-copy-cell]")
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/cards/${rowId}`);
|
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) => {
|
const handleHeaderContextMenu = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||||
|
|
@ -211,10 +346,17 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
<TableHeader onContextMenu={handleHeaderContextMenu}>
|
<TableHeader onContextMenu={handleHeaderContextMenu}>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{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
|
<TableHead
|
||||||
key={header.id}
|
key={header.id}
|
||||||
className="px-4 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground"
|
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
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
|
|
@ -223,7 +365,8 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -268,14 +411,48 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
className="cursor-pointer transition-colors hover:bg-muted/30"
|
className="cursor-pointer transition-colors hover:bg-muted/30"
|
||||||
onClick={(e) => handleRowClick(e, row.original.id)}
|
onClick={(e) => handleRowClick(e, row.original.id)}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => {
|
||||||
<TableCell key={cell.id} className="px-4 py-3">
|
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(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext()
|
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>
|
</TableCell>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -469,13 +646,18 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
<div
|
<div
|
||||||
ref={contextMenuRef}
|
ref={contextMenuRef}
|
||||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
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">
|
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||||
Toggle Columns
|
Toggle Columns
|
||||||
</div>
|
</div>
|
||||||
|
{COLUMN_GROUPS.map((group) => (
|
||||||
|
<React.Fragment key={group.label}>
|
||||||
<div className="my-1 h-px bg-border" />
|
<div className="my-1 h-px bg-border" />
|
||||||
{TOGGLEABLE_COLUMNS.map((col) => (
|
<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
|
<label
|
||||||
key={col.id}
|
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"
|
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||||
|
|
@ -489,6 +671,8 @@ export function DataTable<TData extends ResponseCard>({
|
||||||
{col.label}
|
{col.label}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -13,6 +14,26 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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 = {
|
export type FiltersProps = {
|
||||||
search?: string;
|
search?: string;
|
||||||
|
|
@ -24,6 +45,8 @@ export type FiltersProps = {
|
||||||
serviceAttendedOptions?: string[];
|
serviceAttendedOptions?: string[];
|
||||||
onExportCsv?: () => void;
|
onExportCsv?: () => void;
|
||||||
onUploadClick?: () => void;
|
onUploadClick?: () => void;
|
||||||
|
columnVisibility?: VisibilityState;
|
||||||
|
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Filters({
|
export function Filters({
|
||||||
|
|
@ -36,6 +59,8 @@ export function Filters({
|
||||||
serviceAttendedOptions = [],
|
serviceAttendedOptions = [],
|
||||||
onExportCsv,
|
onExportCsv,
|
||||||
onUploadClick,
|
onUploadClick,
|
||||||
|
columnVisibility = {},
|
||||||
|
onColumnVisibilityChange,
|
||||||
}: FiltersProps) {
|
}: FiltersProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
@ -69,6 +94,13 @@ export function Filters({
|
||||||
updateParams({ search: v || undefined });
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<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">
|
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
|
@ -148,6 +180,52 @@ export function Filters({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<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 && (
|
{onExportCsv && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,29 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
ClipboardCopy,
|
||||||
Download,
|
Download,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
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 { cn } from "@/lib/utils";
|
||||||
|
import { COPYABLE_FIELDS, type ResponseCard } from "./columns";
|
||||||
|
|
||||||
interface SelectionToolbarProps {
|
interface SelectionToolbarProps {
|
||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
|
selectedRows: ResponseCard[];
|
||||||
onMarkReviewed?: (ids: string[]) => void;
|
onMarkReviewed?: (ids: string[]) => void;
|
||||||
onMarkExported?: (ids: string[]) => void;
|
onMarkExported?: (ids: string[]) => void;
|
||||||
onReprocess?: (ids: string[]) => void;
|
onReprocess?: (ids: string[]) => void;
|
||||||
|
|
@ -19,8 +31,26 @@ interface SelectionToolbarProps {
|
||||||
onClear: () => 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({
|
export function SelectionToolbar({
|
||||||
selectedIds,
|
selectedIds,
|
||||||
|
selectedRows,
|
||||||
onMarkReviewed,
|
onMarkReviewed,
|
||||||
onMarkExported,
|
onMarkExported,
|
||||||
onReprocess,
|
onReprocess,
|
||||||
|
|
@ -28,6 +58,47 @@ export function SelectionToolbar({
|
||||||
onClear,
|
onClear,
|
||||||
}: SelectionToolbarProps) {
|
}: SelectionToolbarProps) {
|
||||||
const count = selectedIds.length;
|
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 (
|
return (
|
||||||
<div
|
<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">
|
<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
|
{count} selected
|
||||||
</span>
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="h-4 w-px bg-border/50" />
|
<div className="h-4 w-px bg-border/50" />
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{onMarkReviewed && (
|
{onMarkReviewed && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -77,6 +154,59 @@ export function SelectionToolbar({
|
||||||
<span className="hidden sm:inline ml-1">Reprocess</span>
|
<span className="hidden sm:inline ml-1">Reprocess</span>
|
||||||
</Button>
|
</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 && (
|
{onDelete && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -89,17 +219,6 @@ export function SelectionToolbar({
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue