Files
jorgecuadros-platform/apps/web/src/components/PolicyOcrReview.tsx
T
rmancinas 3125b52057 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
2026-08-02 02:00:02 -07:00

622 lines
20 KiB
TypeScript

"use client";
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,
policyOcrDocumentUrl,
rejectPolicyOcrDocument,
reviewPolicyOcrDocument,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels";
import type {
CustomerListItem,
PolicyOcrBatchDetail,
PolicyOcrConfirmDocument,
PolicyOcrCoverage,
PolicyOcrDocument,
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",
NEEDS_REVIEW: "Para revisar",
MATCHED: "Listo",
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 = [
"NEEDS_REVIEW",
"MATCHED",
"CONFIRMED",
"PENDING_OCR",
"OCR_FAILED",
"REJECTED",
"POSTED",
];
type EditMap = Record<string, PolicyOcrConfirmDocument | undefined>;
export function PolicyOcrReview({ id }: { id: string }) {
const canReview = useCan("policy:ocr-review");
const [batch, setBatch] = useState<PolicyOcrBatchDetail | null>(null);
const [docs, setDocs] = useState<PolicyOcrDocument[]>([]);
const [edits, setEdits] = useState<EditMap>({});
const [customerIndex, setCustomerIndex] = useState<Record<string, CustomerListItem>>({});
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 {
const [b, d, c] = await Promise.all([
getPolicyOcrBatch(id),
listPolicyOcrDocuments(id),
canReview ? listCustomers({ pageSize: 200 }).then((r) => r.items) : Promise.resolve([]),
]);
setBatch(b);
setDocs(d);
setCustomerIndex(Object.fromEntries(c.map((x) => [x.id, x])));
setError(null);
} catch (e) {
setError((e as Error)?.message ?? "No se pudo cargar el lote.");
} finally {
setLoading(false);
}
}, [id, canReview]);
useEffect(() => {
void load();
}, [load]);
const processing = batch?.status === "PROCESSING" || batch?.status === "UPLOADED";
useEffect(() => {
if (!processing) return;
const t = setInterval(() => void load(), 4000);
return () => clearInterval(t);
}, [processing, load]);
const sorted = useMemo(
() =>
[...docs].sort(
(a, b) =>
OPEN_FIRST.indexOf(a.status) - OPEN_FIRST.indexOf(b.status) ||
a.pageNumber - b.pageNumber,
),
[docs],
);
const readyCount = Object.values(edits).filter(Boolean).length;
function setEdit(docId: string, edit: PolicyOcrConfirmDocument) {
setEdits((prev) => ({ ...prev, [docId]: edit }));
}
async function onConfirm() {
if (!batch) return;
const payload: PolicyOcrConfirmDocument[] = [];
for (const d of docs) {
const edit = edits[d.id];
if (!edit) continue;
if (!edit.policyId && !edit.customerId) {
setError(`Página ${d.pageNumber}: falta cliente o póliza destino.`);
return;
}
payload.push(edit);
}
if (!payload.length) {
setError("No hay documentos revisados. Guarde cada página antes de aplicar.");
return;
}
setSubmitting(true);
setError(null);
try {
await confirmPolicyOcrBatch(batch.id, { documents: payload });
setEdits({});
await load();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo aplicar el lote.");
} finally {
setSubmitting(false);
}
}
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">
<div>
<h1 className="page-title">
Pólizas {batch.provider}
{batch.label ? ` · ${batch.label}` : ""}
</h1>
<p className="page-sub">
{formatDate(batch.createdAt)} · {docs.length} página(s) ·{" "}
{STATUS_LABEL[batch.status] ?? batch.status}
</p>
</div>
<Link className="btn btn-ghost" href="/polizas">
Volver a pólizas
</Link>
</header>
{processing && <div className="state-box">Procesando</div>}
{error && <div className="state-box state-error">{error}</div>}
{canReview && readyCount > 0 && (
<section className="card" style={{ padding: 16 }}>
<h2 className="section-title" style={{ marginTop: 0 }}>
Aplicar lote
</h2>
<p className="page-sub" style={{ marginBottom: 12 }}>
{readyCount} página(s) revisada(s). Se creará o actualizará la póliza
y, si marcó la casilla, se registrará la prima en el estado de
cuenta.
</p>
<button
type="button"
className="btn btn-primary"
disabled={submitting}
onClick={onConfirm}
>
{submitting ? "Aplicando…" : "Aplicar"}
</button>
</section>
)}
{canDiscard && (
<DiscardBatchCard
busy={discarding}
onDiscard={onDiscard}
pageCount={docs.length}
what="póliza"
/>
)}
<section className="stack">
{sorted.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
customerIndex={customerIndex}
canReview={canReview}
onSave={async (edit) => {
await reviewPolicyOcrDocument(doc.id, edit.reviewInput);
setEdit(doc.id, edit.confirmInput);
await load();
}}
onReject={async () => {
await rejectPolicyOcrDocument(doc.id);
setEdits((prev) => {
const { [doc.id]: _, ...rest } = prev;
return rest;
});
await load();
}}
/>
))}
</section>
</div>
);
}
interface RowSaved {
reviewInput: PolicyOcrReviewInput;
confirmInput: PolicyOcrConfirmDocument;
}
interface DocumentRowProps {
doc: PolicyOcrDocument;
customerIndex: Record<string, CustomerListItem>;
canReview: boolean;
onSave: (saved: RowSaved) => Promise<void>;
onReject: () => Promise<void>;
}
function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: DocumentRowProps) {
const [v, setV] = useState({
policyNumber: doc.extractedPolicyNumber ?? "",
insuredName: doc.extractedInsuredName ?? "",
additionalInsured: doc.extractedAdditionalInsured ?? "",
agentName: doc.extractedAgentName ?? "",
legalAddress: doc.extractedLegalAddress ?? "",
zip: doc.extractedZip ?? "",
policyFrom: doc.extractedPolicyFrom?.slice(0, 10) ?? "",
policyTo: doc.extractedPolicyTo?.slice(0, 10) ?? "",
policyDate: doc.extractedPolicyDate?.slice(0, 10) ?? "",
currency: doc.extractedCurrency ?? "USD",
netPremium: doc.extractedNetPremium ?? "",
total: doc.extractedTotal ?? "",
premiumPayment: doc.extractedPremiumPayment ?? "",
postPremium: doc.extractedNetPremium != null && Number(doc.extractedNetPremium) > 0,
});
const [customerId, setCustomerId] = useState(
doc.matchedCustomer?.id ?? doc.matchedPolicy?.customerId ?? "",
);
const [customerName, setCustomerName] = useState(
doc.matchedCustomer?.name ?? doc.matchedPolicy?.customer.name ?? "",
);
const [policyId, setPolicyId] = useState(doc.matchedPolicy?.id ?? "");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
function set<K extends keyof typeof v>(k: K, val: (typeof v)[K]) {
setV((p) => ({ ...p, [k]: val }));
}
async function save() {
setBusy(true);
setErr(null);
try {
const numOrUndef = (s: string) => (s.trim() === "" ? undefined : Number(s));
const trimOrUndef = (s: string) => (s.trim() === "" ? undefined : s.trim());
const currency = v.currency || undefined;
const reviewInput: PolicyOcrReviewInput = {
policyNumber: trimOrUndef(v.policyNumber),
insuredName: trimOrUndef(v.insuredName),
additionalInsured: trimOrUndef(v.additionalInsured),
agentName: trimOrUndef(v.agentName),
legalAddress: trimOrUndef(v.legalAddress),
zip: trimOrUndef(v.zip),
policyFrom: v.policyFrom || undefined,
policyTo: v.policyTo || undefined,
policyDate: v.policyDate || undefined,
currency,
netPremium: numOrUndef(v.netPremium),
total: numOrUndef(v.total),
premiumPayment: trimOrUndef(v.premiumPayment),
matchedPolicyId: policyId || undefined,
matchedCustomerId: !policyId && customerId ? customerId : undefined,
forceConfirm: true,
};
const confirmInput: PolicyOcrConfirmDocument = {
documentId: doc.id,
policyId: policyId || undefined,
customerId: !policyId && customerId ? customerId : undefined,
policyNumber: reviewInput.policyNumber,
insuredName: reviewInput.insuredName,
additionalInsured: reviewInput.additionalInsured,
agentName: reviewInput.agentName,
legalAddress: reviewInput.legalAddress,
zip: reviewInput.zip,
policyFrom: reviewInput.policyFrom,
policyTo: reviewInput.policyTo,
policyDate: reviewInput.policyDate,
currency: (currency as "MXN" | "USD" | "EUR" | undefined) ?? undefined,
netPremium: reviewInput.netPremium,
total: reviewInput.total,
premiumPayment: reviewInput.premiumPayment,
coveragesJson: (doc.extractedCoveragesJson ?? undefined) as
| PolicyOcrCoverage[]
| undefined,
postPremium: v.postPremium,
};
await onSave({ reviewInput, confirmInput });
} catch (e) {
setErr((e as Error)?.message ?? "No se pudo guardar.");
} finally {
setBusy(false);
}
}
const locked = doc.status === "POSTED" || doc.status === "REJECTED";
const matchedExisting = !!doc.matchedPolicy;
const candidates = doc.matchCandidates ?? [];
return (
<article className="card" style={{ padding: 16 }}>
<header className="row" style={{ gap: 12, alignItems: "center" }}>
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>
<span className="page-sub">Página {doc.pageNumber}</span>
{doc.extractedPolicyNumber && (
<strong style={{ marginLeft: 8 }}>{doc.extractedPolicyNumber}</strong>
)}
{doc.extractedInsuredName && (
<span className="page-sub">· {doc.extractedInsuredName}</span>
)}
</header>
<div className="doc-detail">
{/*
* Embed the source PDF the office uploaded. One PDF = one parsed
* policy, so the browser's PDF viewer handles multi-page navigation
* natively; we don't need to render individual pages on the server.
*/}
<iframe
src={policyOcrDocumentUrl(doc.id)}
title={`Póliza ${doc.extractedPolicyNumber ?? doc.pageNumber}`}
style={{
width: "100%",
height: 720,
border: "1px solid var(--border, #ddd)",
borderRadius: 6,
background: "#fff",
}}
/>
<div className="stack" style={{ flex: 1, minWidth: 0 }}>
{doc.matchNote && <p className="page-sub">{doc.matchNote}</p>}
{matchedExisting ? (
<div className="state-box">
Coincide con la póliza{" "}
<strong>{doc.matchedPolicy?.policyNumber}</strong> del cliente{" "}
<strong>{doc.matchedPolicy?.customer.name}</strong>.
</div>
) : candidates.length > 1 ? (
<div className="state-box state-warn">
{candidates.length} pólizas comparten este número. Elija
manualmente abajo.
</div>
) : (
<div className="state-box">
No se encontró una póliza con este número. Se creará una nueva
bajo el cliente que elija abajo.
</div>
)}
<fieldset className="form-grid" disabled={locked || !canReview}>
<Field label="Número de póliza">
<input
className="input"
value={v.policyNumber}
onChange={(e) => set("policyNumber", e.target.value)}
/>
</Field>
<Field label="Asegurado">
<input
className="input"
value={v.insuredName}
onChange={(e) => set("insuredName", e.target.value)}
/>
</Field>
<Field label="Asegurado adicional">
<input
className="input"
value={v.additionalInsured}
onChange={(e) => set("additionalInsured", e.target.value)}
/>
</Field>
<Field label="Agente">
<input
className="input"
value={v.agentName}
onChange={(e) => set("agentName", e.target.value)}
/>
</Field>
<Field label="Desde">
<input
className="input"
type="date"
value={v.policyFrom}
onChange={(e) => set("policyFrom", e.target.value)}
/>
</Field>
<Field label="Hasta">
<input
className="input"
type="date"
value={v.policyTo}
onChange={(e) => set("policyTo", e.target.value)}
/>
</Field>
<Field label="Fecha de firma">
<input
className="input"
type="date"
value={v.policyDate}
onChange={(e) => set("policyDate", e.target.value)}
/>
</Field>
<Field label="Moneda">
<select
className="input select"
value={v.currency}
onChange={(e) => set("currency", e.target.value)}
>
<option value="MXN">MXN</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</Field>
<Field label="Prima neta">
<input
className="input"
type="number"
step="0.01"
value={v.netPremium}
onChange={(e) => set("netPremium", e.target.value)}
/>
</Field>
<Field label="Total">
<input
className="input"
type="number"
step="0.01"
value={v.total}
onChange={(e) => set("total", e.target.value)}
/>
</Field>
<Field label="Pago de prima">
<input
className="input"
value={v.premiumPayment}
onChange={(e) => set("premiumPayment", e.target.value)}
/>
</Field>
<Field label="Dirección">
<input
className="input"
value={v.legalAddress}
onChange={(e) => set("legalAddress", e.target.value)}
/>
</Field>
<Field label="C.P.">
<input
className="input"
value={v.zip}
onChange={(e) => set("zip", e.target.value)}
/>
</Field>
</fieldset>
{doc.extractedCoveragesJson && doc.extractedCoveragesJson.length > 0 && (
<details>
<summary>
Coberturas ({doc.extractedCoveragesJson.length}) ·{" "}
{formatMoney(
doc.extractedCoveragesJson
.map((c) => Number(c.insuredAmount ?? 0))
.reduce((a, b) => a + b, 0)
.toString(),
v.currency,
)}
</summary>
<table className="tx-table" style={{ marginTop: 8 }}>
<thead>
<tr>
<th>Riesgo</th>
<th className="num">Suma</th>
<th>Deducible</th>
<th>Participación</th>
</tr>
</thead>
<tbody>
{doc.extractedCoveragesJson.map((c, i) => (
<tr key={i}>
<td>{c.risk}</td>
<td className="num">
{formatMoney(c.insuredAmount?.toString() ?? null, v.currency)}
</td>
<td>{c.deductible ?? "—"}</td>
<td>{c.lossParticipation ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</details>
)}
{candidates.length > 1 && (
<Field label="Póliza destino">
<select
className="input select"
value={policyId}
onChange={(e) => {
setPolicyId(e.target.value);
const found = candidates.find((c) => c.policyId === e.target.value);
if (found) {
setCustomerId(found.customerId);
setCustomerName(customerIndex[found.customerId]?.name ?? found.customerName);
}
}}
>
<option value=""> elegir póliza </option>
{candidates.map((c) => (
<option key={c.policyId} value={c.policyId}>
{c.policyNumber} · {c.customerName}
</option>
))}
</select>
</Field>
)}
{!policyId && (
<Field label={matchedExisting ? "Cliente" : "Cliente (póliza nueva)"}>
<CustomerPicker
value={customerId}
valueName={customerName}
onPick={(id, name) => {
setCustomerId(id);
setCustomerName(name);
}}
/>
</Field>
)}
<label className="field">
<input
type="checkbox"
checked={v.postPremium}
onChange={(e) => set("postPremium", e.target.checked)}
disabled={!v.netPremium || Number(v.netPremium) <= 0}
/>{" "}
Registrar prima en el estado de cuenta
</label>
{err && <div className="state-box state-error">{err}</div>}
{!locked && canReview && (
<div className="row" style={{ gap: 8 }}>
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>
{busy ? "Guardando…" : "Guardar revisión"}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => void onReject()}
>
Rechazar
</button>
</div>
)}
</div>
</div>
</article>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="field">
<span className="field-label">{label}</span>
{children}
</label>
);
}