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,
|
||||
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 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue