diff --git a/src/app/api/cards/reprocess-batch/route.ts b/src/app/api/cards/reprocess-batch/route.ts new file mode 100644 index 0000000..12b0865 --- /dev/null +++ b/src/app/api/cards/reprocess-batch/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import { reprocessCard } from "@/lib/ocr"; + +const DELAY_BETWEEN_CARDS_MS = 3_000; + +export async function POST(request: NextRequest) { + try { + const body = await request.json().catch(() => ({})); + const ids = body.ids as string[] | undefined; + + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return NextResponse.json({ error: "No card IDs provided" }, { status: 400 }); + } + + const cards = await prisma.responseCard.findMany({ + where: { id: { in: ids } }, + select: { id: true, ocrStatus: true, backImagePath: true, frontImagePath: true }, + }); + + const eligible = cards.filter( + (c) => c.ocrStatus !== "processing" && (c.backImagePath || c.frontImagePath) + ); + + if (eligible.length === 0) { + return NextResponse.json( + { error: "No eligible cards (already processing or no images)" }, + { status: 400 } + ); + } + + await prisma.responseCard.updateMany({ + where: { id: { in: eligible.map((c) => c.id) } }, + data: { ocrStatus: "processing", ocrError: null }, + }); + + (async () => { + for (let i = 0; i < eligible.length; i++) { + try { + await reprocessCard(eligible[i].id); + } catch (err) { + console.error(`[reprocess-batch] Card ${eligible[i].id} failed:`, err); + } + if (i < eligible.length - 1) { + await new Promise((r) => setTimeout(r, DELAY_BETWEEN_CARDS_MS)); + } + } + console.log(`[reprocess-batch] Completed ${eligible.length} card(s)`); + })().catch((err) => { + console.error("[reprocess-batch] Background task failed:", err); + }); + + return NextResponse.json({ + ok: true, + queued: eligible.length, + skipped: ids.length - eligible.length, + }); + } catch (error) { + console.error("[reprocess-batch POST]", error); + return NextResponse.json({ error: "Failed to start batch reprocessing" }, { status: 500 }); + } +} diff --git a/src/components/cards/dashboard-content.tsx b/src/components/cards/dashboard-content.tsx index f9a09c0..d806ec2 100644 --- a/src/components/cards/dashboard-content.tsx +++ b/src/components/cards/dashboard-content.tsx @@ -145,6 +145,28 @@ export function DashboardContent() { } }; + 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 handleExportCsv = () => { if (data.length === 0) { toast.error("No data to export"); @@ -344,6 +366,7 @@ export function DashboardContent() { selectedIds={selectedIds} onMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")} onMarkExported={(ids) => handleBulkAction(ids, "exported")} + onReprocess={handleBulkReprocess} onDelete={(ids) => handleBulkAction(ids, "delete")} onClear={() => setSelectedIds([])} /> diff --git a/src/components/cards/selection-toolbar.tsx b/src/components/cards/selection-toolbar.tsx index ad41359..605918a 100644 --- a/src/components/cards/selection-toolbar.tsx +++ b/src/components/cards/selection-toolbar.tsx @@ -3,6 +3,7 @@ import { CheckCircle, Download, + RefreshCw, Trash2, X, } from "lucide-react"; @@ -13,6 +14,7 @@ interface SelectionToolbarProps { selectedIds: string[]; onMarkReviewed?: (ids: string[]) => void; onMarkExported?: (ids: string[]) => void; + onReprocess?: (ids: string[]) => void; onDelete?: (ids: string[]) => void; onClear: () => void; } @@ -21,6 +23,7 @@ export function SelectionToolbar({ selectedIds, onMarkReviewed, onMarkExported, + onReprocess, onDelete, onClear, }: SelectionToolbarProps) { @@ -63,6 +66,17 @@ export function SelectionToolbar({ Export )} + {onReprocess && ( + + )} {onDelete && (