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
622 lines
20 KiB
TypeScript
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>
|
|
);
|
|
} |