echos-ocr/src/components/cards/dashboard-content.tsx
Randall Stillwell d6d209cf9d Add Monday.com sync-all with push+update support and batch toolbar button
- Sync-all endpoint now supports mode param: "push" (new only), "update"
  (existing only), or "all" (both); also accepts cardIds for batch ops
- Response includes separate created/updated/failed counts
- Settings button renamed to "Sync All to Monday.com" and shows breakdown
- Add "Monday.com" button to the floating selection toolbar for syncing
  selected cards in batch

Made-with: Cursor
2026-04-09 15:14:48 -05:00

472 lines
15 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, 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<ResponseCard[]>([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true);
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[]>([]);
const [activeStatFilter, setActiveStatFilter] = React.useState<StatFilter>(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<string, unknown>) => ({
...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<string, string | number | undefined>) => {
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<string, unknown>)[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 (
<div className="space-y-6">
{/* Global drag overlay */}
{globalDragOver && (
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-primary/5 backdrop-blur-sm">
<div className="rounded-2xl border-2 border-dashed border-primary/40 bg-background/80 px-12 py-8 text-center shadow-xl">
<p className="text-lg font-semibold">Drop files to upload</p>
<p className="mt-1 text-sm text-muted-foreground">
PDF, JPEG, PNG, WebP
</p>
</div>
</div>
)}
{/* Page title */}
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
Response Cards
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Manage and review scanned response cards
</p>
</div>
{/* Stat cards as filters */}
<StatCards
activeFilter={activeStatFilter}
onFilterChange={handleStatFilterChange}
/>
{/* Filter toolbar */}
<Filters
visitTypeOptions={VISIT_TYPE_OPTIONS}
attendanceDurationOptions={ATTENDANCE_OPTIONS}
serviceAttendedOptions={SERVICE_OPTIONS}
onExportCsv={handleExportCsv}
onUploadClick={openUpload}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
/>
{/* Data table */}
<div className={loading ? "opacity-60 transition-opacity" : ""}>
<DataTable
data={data}
columns={columns}
totalCount={total}
page={page}
limit={limit}
sortBy={sortBy}
sortOrder={sortOrder}
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
onPageChange={(p) => 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}
/>
</div>
{/* Floating selection toolbar */}
<SelectionToolbar
selectedIds={selectedIds}
selectedRows={selectedRows}
onMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
onMarkExported={(ids) => handleBulkAction(ids, "exported")}
onReprocess={handleBulkReprocess}
onSyncMonday={handleBulkSyncMonday}
onDelete={(ids) => handleBulkAction(ids, "delete")}
onClear={() => setSelectedIds([])}
/>
{/* Upload modal */}
<UploadModal
open={uploadModalOpen}
onOpenChange={setUploadModalOpen}
onUploadStart={handleUploadStart}
onUploadProgress={handleUploadProgress}
onUploadComplete={handleUploadComplete}
onUploadError={handleUploadError}
/>
</div>
);
}