"use client"; import * as React from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "sonner"; import { StatCards, type StatFilter } from "./stat-cards"; import { Filters } from "./filters"; import { DataTable, getDefaultColumnVisibility, ALL_TOGGLEABLE_COLUMNS } from "./data-table"; import { SelectionToolbar } from "./selection-toolbar"; import { UploadModal, type UploadingFile } from "./upload-modal"; import { createColumns, COPYABLE_FIELDS, type ResponseCard } from "./columns"; const VISIT_TYPE_OPTIONS = [ "First/Second Time Guest", "Update My Information", ]; const ATTENDANCE_OPTIONS = [ "Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years", ]; const SERVICE_OPTIONS = ["A", "B", "C", "D"]; export function DashboardContent() { const router = useRouter(); const searchParams = useSearchParams(); const page = parseInt(searchParams.get("page") || "1"); const limit = parseInt(searchParams.get("limit") || "20"); const sortBy = searchParams.get("sortBy") || "createdAt"; const sortOrder = (searchParams.get("sortOrder") || "desc") as "asc" | "desc"; const [data, setData] = React.useState([]); const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(true); const [columnVisibility, setColumnVisibility] = React.useState>(getDefaultColumnVisibility); const [selectedIds, setSelectedIds] = React.useState([]); const [uploadModalOpen, setUploadModalOpen] = React.useState(false); const [uploadingRows, setUploadingRows] = React.useState([]); const [activeStatFilter, setActiveStatFilter] = React.useState(null); const [globalDragOver, setGlobalDragOver] = React.useState(false); const fetchCards = React.useCallback(async () => { setLoading(true); try { const params = new URLSearchParams(searchParams.toString()); if (!params.has("page")) params.set("page", "1"); if (!params.has("limit")) params.set("limit", "20"); const res = await fetch(`/api/cards?${params.toString()}`); if (!res.ok) throw new Error("Failed to fetch cards"); const json = await res.json(); const cards = json.cards.map((card: Record) => ({ ...card, createdAt: card.createdAt as string, frontImageUrl: card.frontImagePath ? `/api/images/${card.frontImagePath}` : null, backImageUrl: card.backImagePath ? `/api/images/${card.backImagePath}` : null, })); setData(cards); setTotal(json.total); } catch { toast.error("Failed to load cards"); } finally { setLoading(false); } }, [searchParams]); React.useEffect(() => { fetchCards(); }, [fetchCards]); const updateUrl = React.useCallback( (updates: Record) => { const params = new URLSearchParams(searchParams.toString()); for (const [key, value] of Object.entries(updates)) { if (value === undefined || value === "") { params.delete(key); } else { params.set(key, String(value)); } } router.push(`/?${params.toString()}`); }, [router, searchParams] ); const handleStatFilterChange = React.useCallback( (filter: StatFilter) => { setActiveStatFilter(filter); const params = new URLSearchParams(searchParams.toString()); params.delete("ocrStatus"); params.delete("reviewStatus"); if (filter === "complete") { params.set("ocrStatus", "complete"); } else if (filter === "error") { params.set("ocrStatus", "error"); } else if (filter === "unreviewed") { params.set("reviewStatus", "unreviewed"); } params.set("page", "1"); router.push(`/?${params.toString()}`); }, [router, searchParams] ); const handleBulkAction = async ( ids: string[], action: "reviewed" | "exported" | "delete" ) => { try { if (action === "delete") { await Promise.all( ids.map((id) => fetch(`/api/cards/${id}`, { method: "DELETE" })) ); toast.success(`Deleted ${ids.length} card(s)`); } else { await Promise.all( ids.map((id) => fetch(`/api/cards/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reviewStatus: action }), }) ) ); toast.success(`Marked ${ids.length} card(s) as ${action}`); } fetchCards(); } catch { toast.error("Bulk action failed"); } }; const handleBulkReprocess = async (ids: string[]) => { try { const res = await fetch("/api/cards/reprocess-batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids }), }); const data = await res.json(); if (res.ok) { toast.success( `Reprocessing ${data.queued} card(s)${data.skipped > 0 ? ` (${data.skipped} skipped)` : ""}` ); setSelectedIds([]); fetchCards(); } else { toast.error(data.error || "Batch reprocess failed"); } } catch { toast.error("Failed to start batch reprocessing"); } }; const handleBulkSyncMonday = async (ids: string[]) => { try { toast.info(`Syncing ${ids.length} card(s) to Monday.com…`); const res = await fetch("/api/integrations/monday/sync-all", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cardIds: ids, mode: "all" }), }); const result = await res.json(); if (!res.ok) { toast.error(result.error || "Monday.com sync failed"); return; } const parts: string[] = []; if (result.created > 0) parts.push(`${result.created} pushed`); if (result.updated > 0) parts.push(`${result.updated} updated`); if (result.failed > 0) parts.push(`${result.failed} failed`); if (result.failed > 0 && result.synced === 0) { toast.error(`Monday.com: ${parts.join(", ")}`, { description: result.errors?.[0], }); } else if (result.failed > 0) { toast.warning(`Monday.com: ${parts.join(", ")}`); } else if (result.synced === 0) { toast.info("No eligible cards to sync"); } else { toast.success(`Monday.com: ${parts.join(", ")}`); } fetchCards(); } catch { toast.error("Failed to sync to Monday.com"); } }; const handleExportCsv = () => { if (data.length === 0) { toast.error("No data to export"); return; } const visibleCols = ALL_TOGGLEABLE_COLUMNS.filter( (col) => columnVisibility[col.id] !== false ); if (visibleCols.length === 0) { toast.error("No columns visible to export"); return; } const headers = visibleCols.map((c) => c.label); const rows = data.map((card) => visibleCols.map((col) => { const val = (card as Record)[col.id]; if (val == null) return ""; if (typeof val === "boolean") return val ? "Yes" : "No"; if (Array.isArray(val)) return val.filter(Boolean).join(", "); return String(val); }) ); const csv = [headers, ...rows] .map((r) => r.map((v) => `"${v.replace(/"/g, '""')}"`).join(",")) .join("\n"); const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `response-cards-${new Date().toISOString().split("T")[0]}.csv`; a.click(); URL.revokeObjectURL(url); toast.success(`Exported ${data.length} rows × ${visibleCols.length} columns`); }; const columns = React.useMemo( () => createColumns({ onViewDetails: (card) => router.push(`/cards/${card.id}`), onMarkReviewed: async (card) => { await fetch(`/api/cards/${card.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reviewStatus: "reviewed" }), }); toast.success("Marked as reviewed"); fetchCards(); }, onReprocess: async (card) => { try { const res = await fetch(`/api/cards/${card.id}/reprocess`, { method: "POST", }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Failed to start reprocessing"); } toast.success("Reprocessing started"); fetchCards(); } catch (err) { toast.error( err instanceof Error ? err.message : "Failed to start reprocessing" ); } }, onDelete: async (card) => { await fetch(`/api/cards/${card.id}`, { method: "DELETE" }); toast.success("Card deleted"); fetchCards(); }, }), [router, fetchCards] ); const handleUploadStart = (files: UploadingFile[]) => { setUploadingRows((prev) => [...prev, ...files]); }; const handleUploadProgress = (fileId: string, progress: number) => { setUploadingRows((prev) => prev.map((f) => (f.id === fileId ? { ...f, progress } : f)) ); }; const handleUploadComplete = (fileId: string) => { setUploadingRows((prev) => prev.filter((f) => f.id !== fileId)); fetchCards(); }; const handleUploadError = (fileId: string, error: string) => { setUploadingRows((prev) => prev.map((f) => f.id === fileId ? { ...f, status: "error" as const, error } : f ) ); }; 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); return () => window.removeEventListener("open-upload-modal", handleOpenUpload); }, []); React.useEffect(() => { const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer?.types.includes("Files")) { setGlobalDragOver(true); } }; const handleDragLeave = (e: DragEvent) => { if (e.relatedTarget === null) { setGlobalDragOver(false); } }; const handleDrop = (e: DragEvent) => { e.preventDefault(); setGlobalDragOver(false); if (e.dataTransfer?.files.length) { setUploadModalOpen(true); } }; document.addEventListener("dragover", handleDragOver); document.addEventListener("dragleave", handleDragLeave); document.addEventListener("drop", handleDrop); return () => { document.removeEventListener("dragover", handleDragOver); document.removeEventListener("dragleave", handleDragLeave); document.removeEventListener("drop", handleDrop); }; }, []); const openUpload = React.useCallback(() => { setUploadModalOpen(true); }, []); return (
{/* Global drag overlay */} {globalDragOver && (

Drop files to upload

PDF, JPEG, PNG, WebP

)} {/* Page title */}

Response Cards

Manage and review scanned response cards

{/* Stat cards as filters */} {/* Filter toolbar */} {/* Data table */}
updateUrl({ page: p })} onLimitChange={(l) => updateUrl({ limit: l, page: 1 })} onSortChange={(sb, so) => updateUrl({ sortBy: sb, sortOrder: so })} uploadingRows={uploadingRows.filter((f) => f.status === "uploading")} onSelectionChange={setSelectedIds} />
{/* Floating selection toolbar */} handleBulkAction(ids, "reviewed")} onMarkExported={(ids) => handleBulkAction(ids, "exported")} onReprocess={handleBulkReprocess} onSyncMonday={handleBulkSyncMonday} onDelete={(ids) => handleBulkAction(ids, "delete")} onClear={() => setSelectedIds([])} /> {/* Upload modal */}
); }