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 && (