"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 = { 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; export function PolicyOcrReview({ id }: { id: string }) { const canReview = useCan("policy:ocr-review"); const [batch, setBatch] = useState(null); const [docs, setDocs] = useState([]); const [edits, setEdits] = useState({}); const [customerIndex, setCustomerIndex] = useState>({}); const [error, setError] = useState(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
Cargando…
; if (!batch) return
{error ?? "No encontrado."}
; 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 (

Pólizas — {batch.provider} {batch.label ? ` · ${batch.label}` : ""}

{formatDate(batch.createdAt)} · {docs.length} página(s) ·{" "} {STATUS_LABEL[batch.status] ?? batch.status}

Volver a pólizas
{processing &&
Procesando…
} {error &&
{error}
} {canReview && readyCount > 0 && (

Aplicar lote

{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.

)} {canDiscard && ( )}
{sorted.map((doc) => ( { 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(); }} /> ))}
); } interface RowSaved { reviewInput: PolicyOcrReviewInput; confirmInput: PolicyOcrConfirmDocument; } interface DocumentRowProps { doc: PolicyOcrDocument; customerIndex: Record; canReview: boolean; onSave: (saved: RowSaved) => Promise; onReject: () => Promise; } 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(null); function set(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 (
{STATUS_LABEL[doc.status] ?? doc.status} Página {doc.pageNumber} {doc.extractedPolicyNumber && ( {doc.extractedPolicyNumber} )} {doc.extractedInsuredName && ( · {doc.extractedInsuredName} )}
{/* * 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. */}