echos-ocr/src/components/cards/data-table.tsx
Randall Stillwell 0ab9932599 Add multi-user card review workflow with role-based access
- Add User model synced from Authentik headers with admin/reviewer/viewer roles
- Add assignment fields (assignedToId, assignedById, reviewedById, etc.) to ResponseCard
- Add userId tracking to ActivityLog and Notification models
- Create auth.ts with getOrCreateUser() and role mapping from Authentik groups
- Create /api/users endpoint and /api/cards/assign batch assignment endpoint
- Gate card mutations behind role checks (viewers read-only, reviewers edit assigned only)
- Gate Monday.com push behind reviewStatus=reviewed instead of ocr_complete
- Add "My Cards" stat card, Assigned To filter, and assignment columns to table
- Add Assign button with user picker to batch selection toolbar (admin only)
- Update card detail: assignment banner, Mark Complete button, prev/next nav, reassign
- Make all field components accept readOnly prop for role-based editing
- Gate settings page behind admin role
- Add userId to stats API for per-user card counts
- Expose dbUser and role through UserProfileProvider context

Made-with: Cursor
2026-04-11 00:00:11 -05:00

675 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import {
type ColumnDef,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
ChevronLeft,
ChevronRight,
Loader2,
Copy,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { ResponseCard } from "./columns";
import type { UploadingFile } from "./upload-modal";
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: "firstTimeGuestDate", label: "FT Guest Date" },
{ id: "salvationDate", label: "Salvation Date" },
{ id: "mondayLinked", label: "Monday.com" },
{ id: "assignedTo", label: "Assigned To" },
{ id: "reviewedBy", label: "Reviewed By" },
],
},
];
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", "firstTimeGuestDate", "salvationDate", "mondayLinked",
"assignedTo", "reviewedBy",
];
export function getDefaultColumnVisibility(): VisibilityState {
const vis: VisibilityState = {};
for (const id of DEFAULT_HIDDEN) {
vis[id] = false;
}
return vis;
}
type DataTableProps<TData> = {
data: TData[];
columns: ColumnDef<TData, unknown>[];
totalCount: number;
page: number;
limit: number;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
sortBy?: string;
sortOrder?: "asc" | "desc";
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
uploadingRows?: UploadingFile[];
onSelectionChange?: (selectedIds: string[]) => void;
};
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,
totalCount,
page,
limit,
onPageChange,
onLimitChange,
columnVisibility: controlledVisibility,
onColumnVisibilityChange,
sortBy: controlledSortBy,
sortOrder: controlledSortOrder,
onSortChange,
uploadingRows = [],
onSelectionChange,
}: DataTableProps<TData>) {
const router = useRouter();
const sorting: SortingState =
controlledSortBy && controlledSortOrder
? [{ id: controlledSortBy, desc: controlledSortOrder === "desc" }]
: [];
const setSorting = React.useCallback(
(updater: React.SetStateAction<SortingState>) => {
const next =
typeof updater === "function" ? updater(sorting) : updater;
const first = next[0];
if (first && onSortChange) {
onSortChange(first.id, first.desc ? "desc" : "asc");
}
},
[sorting, onSortChange]
);
const [rowSelection, setRowSelection] = React.useState({});
const [internalVisibility, setInternalVisibility] =
React.useState<VisibilityState>(getDefaultColumnVisibility());
const visibility =
controlledVisibility !== undefined ? controlledVisibility : internalVisibility;
const setVisibility =
onColumnVisibilityChange ?? setInternalVisibility;
const [contextMenu, setContextMenu] = React.useState<{
x: number;
y: number;
} | null>(null);
const contextMenuRef = React.useRef<HTMLDivElement>(null);
const [copiedCellId, setCopiedCellId] = React.useState<string | null>(null);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: (updater) => {
const next =
typeof updater === "function" ? updater(visibility) : updater;
setVisibility(next);
},
state: {
sorting,
rowSelection,
columnVisibility: visibility,
pagination: {
pageIndex: page - 1,
pageSize: limit,
},
},
manualPagination: true,
manualSorting: !!onSortChange,
pageCount: Math.ceil(totalCount / limit) || 1,
});
const selectedRows = table.getFilteredSelectedRowModel().rows;
const selectedIds = React.useMemo(
() => selectedRows.map((r) => r.original.id),
[selectedRows]
);
React.useEffect(() => {
onSelectionChange?.(selectedIds);
}, [selectedIds, onSelectionChange]);
const totalPages = Math.ceil(totalCount / limit);
const canPrev = page > 1;
const canNext = page < totalPages;
const start = (page - 1) * limit + 1;
const end = Math.min(page * limit, totalCount);
const handleRowClick = (e: React.MouseEvent, rowId: string) => {
const target = e.target as HTMLElement;
if (
target.closest("button") ||
target.closest("input") ||
target.closest('[data-slot="checkbox"]') ||
target.closest('[data-slot="dropdown-menu"]')
) {
return;
}
router.push(`/cards/${rowId}`);
};
const handleCopyClick = React.useCallback(
async (e: React.MouseEvent, cellElement: HTMLTableCellElement, cellId: string) => {
e.stopPropagation();
const text = getCellText(cellElement);
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 });
};
React.useEffect(() => {
if (!contextMenu) return;
const close = (e: MouseEvent) => {
if (
contextMenuRef.current &&
contextMenuRef.current.contains(e.target as Node)
) {
return;
}
setContextMenu(null);
};
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
};
}, [contextMenu]);
const isColumnVisible = (id: string) => visibility[id] !== false;
const toggleColumn = (id: string, visible: boolean) => {
setVisibility({ ...visibility, [id]: visible });
};
return (
<div className="space-y-4">
{/* Desktop table */}
<div className="glass-card overflow-hidden rounded-2xl hidden md:block">
<div className="overflow-x-auto">
<Table>
<TableHeader onContextMenu={handleHeaderContextMenu}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{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>
<TableBody>
{uploadingRows.map((uf) => (
<TableRow key={uf.id} className="opacity-70">
<TableCell className="px-4 py-3" />
<TableCell className="px-4 py-3" />
<TableCell className="px-4 py-3">
<span className="font-medium text-muted-foreground">
{uf.name}
</span>
</TableCell>
<TableCell className="px-4 py-3" colSpan={6} />
<TableCell className="px-4 py-3">
<div className="flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin text-primary" />
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${uf.progress}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">
{uf.progress}%
</span>
</div>
</div>
</TableCell>
<TableCell className="px-4 py-3" />
<TableCell className="px-4 py-3" />
<TableCell className="px-4 py-3" />
</TableRow>
))}
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer transition-colors hover:bg-muted/30"
onClick={(e) => handleRowClick(e, row.original.id)}
>
{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;
const cellRef = React.createRef<HTMLTableCellElement>();
return (
<TableCell
key={cell.id}
ref={cellRef}
className={cn(
"px-4 py-3 relative group/cell",
isSticky && "sticky left-0 z-10 bg-background",
isCopied && "ring-2 ring-primary/40 ring-inset"
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
{isCopyable && (
<button
type="button"
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/cell:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
onClick={(e) => {
if (cellRef.current) handleCopyClick(e, cellRef.current, cellKey);
}}
>
{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" />
)}
</button>
)}
</TableCell>
);
})}
</TableRow>
))
) : (
uploadingRows.length === 0 && (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-muted-foreground"
>
No results.
</TableCell>
</TableRow>
)
)}
</TableBody>
</Table>
</div>
</div>
{/* Mobile card layout */}
<div className="space-y-3 md:hidden">
{uploadingRows.map((uf) => (
<div
key={uf.id}
className="glass-card rounded-2xl p-4 opacity-70"
>
<div className="flex items-center gap-3">
<Loader2 className="size-4 animate-spin text-primary" />
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{uf.name}</p>
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${uf.progress}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">
{uf.progress}%
</span>
</div>
</div>
</div>
</div>
))}
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<div
key={row.id}
className={cn(
"glass-card cursor-pointer rounded-2xl p-4 transition-all hover:shadow-lg active:scale-[0.99]",
row.getIsSelected() && "ring-2 ring-primary/30"
)}
onClick={(e) => handleRowClick(e, row.original.id)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-semibold">
{row.original.name ?? "Unnamed"}
</p>
{row.original.email && (
<p className="truncate text-xs text-muted-foreground mt-0.5">
{row.original.email}
</p>
)}
</div>
<div className="flex items-center gap-1.5">
<Badge
variant="secondary"
className={cn(
"capitalize text-[10px] px-1.5 py-0.5",
row.original.ocrStatus === "complete"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
: row.original.ocrStatus === "error"
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
: "bg-muted text-muted-foreground"
)}
>
{row.original.ocrStatus}
</Badge>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{row.original.cellPhone && (
<span>{row.original.cellPhone}</span>
)}
{row.original.visitType && (
<span>{row.original.visitType}</span>
)}
{row.original.ocrConfidence != null && (
<span
className={cn(
"font-medium",
row.original.ocrConfidence >= 75
? "text-green-600 dark:text-green-400"
: row.original.ocrConfidence >= 50
? "text-amber-600 dark:text-amber-400"
: "text-red-600 dark:text-red-400"
)}
>
{Math.round(row.original.ocrConfidence)}%
</span>
)}
</div>
</div>
))
) : (
uploadingRows.length === 0 && (
<div className="py-12 text-center text-sm text-muted-foreground">
No results.
</div>
)
)}
</div>
{/* Pagination */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<span>
{totalCount === 0
? "0 results"
: `${start}${end} of ${totalCount}`}
</span>
<div className="flex items-center gap-2">
<span className="text-xs">Rows</span>
<select
value={limit}
onChange={(e) => onLimitChange?.(Number(e.target.value))}
className="h-8 rounded-xl border border-input bg-transparent px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
{PAGE_SIZES.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => onPageChange?.(page - 1)}
disabled={!canPrev}
>
<ChevronLeft className="size-4" />
<span className="hidden sm:inline ml-1">Prev</span>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let p: number;
if (totalPages <= 5) {
p = i + 1;
} else if (page <= 3) {
p = i + 1;
} else if (page >= totalPages - 2) {
p = totalPages - 4 + i;
} else {
p = page - 2 + i;
}
return (
<Button
key={p}
variant={p === page ? "default" : "ghost"}
size="sm"
className="size-8 rounded-xl p-0"
onClick={() => onPageChange?.(p)}
>
{p}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
className="rounded-xl"
onClick={() => onPageChange?.(page + 1)}
disabled={!canNext}
>
<span className="hidden sm:inline mr-1">Next</span>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
{/* Right-click column visibility context menu */}
{contextMenu && (
<div
ref={contextMenuRef}
style={{ left: contextMenu.x, top: contextMenu.y }}
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>
{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>
)}
</div>
);
}