feat(ocr): discard abandoned capture batches

A bad scan, the wrong PDFs or a duplicate upload used to leave a batch
sitting in READY_FOR_REVIEW forever, because the only exits were confirm
(posts to the books) or rejecting every page one at a time. Add a
DISCARDED terminal status to both OCR domains and a single endpoint per
domain that rejects every page still pending in one shot.

Discarding is refused once anything has landed: statements once a page is
POSTED, policies once a page is APPLIED. Those batches did real work and
have to be settled page by page.

- POST /statements/batches/:id/discard
- POST /policy-ocr/batches/:id/discard
- shared DiscardBatchCard on both review screens, gated the same way
This commit is contained in:
2026-08-02 02:00:02 -07:00
parent 905fa31e47
commit 3125b52057
13 changed files with 327 additions and 8 deletions
@@ -3,8 +3,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { CustomerPicker } from "@/components/CustomerPicker";
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
import {
confirmPolicyOcrBatch,
discardPolicyOcrBatch,
getPolicyOcrBatch,
listCustomers,
listPolicyOcrDocuments,
@@ -23,6 +25,8 @@ import type {
PolicyOcrReviewInput,
} from "@/lib/types";
/** Document and batch statuses share this map — the two enums have no
* overlapping members, and the header renders a batch status through it. */
const STATUS_LABEL: Record<string, string> = {
PENDING_OCR: "Pendiente",
OCR_FAILED: "Falló OCR",
@@ -31,6 +35,12 @@ const STATUS_LABEL: Record<string, string> = {
CONFIRMED: "Confirmado",
POSTED: "Aplicado",
REJECTED: "Rechazado",
UPLOADED: "Recibido",
PROCESSING: "Procesando…",
READY_FOR_REVIEW: "Listo para revisar",
COMPLETED: "Aplicado",
FAILED: "Falló",
DISCARDED: "Descartado",
};
const OPEN_FIRST = [
@@ -54,6 +64,7 @@ export function PolicyOcrReview({ id }: { id: string }) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [discarding, setDiscarding] = useState(false);
const load = useCallback(async () => {
try {
@@ -129,9 +140,33 @@ export function PolicyOcrReview({ id }: { id: string }) {
}
}
async function onDiscard() {
if (!batch) return;
setDiscarding(true);
setError(null);
try {
await discardPolicyOcrBatch(batch.id);
setEdits({});
await load();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo descartar el lote.");
} finally {
setDiscarding(false);
}
}
if (loading) return <div className="state-box">Cargando</div>;
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
const appliedCount = docs.filter((d) => d.status === "POSTED").length;
// Discarding is only offered while the batch can still be abandoned whole:
// nothing applied yet, and not already discarded.
const canDiscard =
canReview &&
batch.status !== "DISCARDED" &&
batch.status !== "COMPLETED" &&
appliedCount === 0;
return (
<div className="stack">
<header className="page-head">
@@ -175,6 +210,15 @@ export function PolicyOcrReview({ id }: { id: string }) {
</section>
)}
{canDiscard && (
<DiscardBatchCard
busy={discarding}
onDiscard={onDiscard}
pageCount={docs.length}
what="póliza"
/>
)}
<section className="stack">
{sorted.map((doc) => (
<DocumentRow