feat(polizas): OCR capture for insurance policy PDFs
Mirrors the utility statement intake on the insurance side: a policy_ocr batch/document pair of tables, a GMX parser, a matcher keyed on Policy.policyNumber, and a "Captura" screen under /polizas that proposes policy -> customer for staff to confirm. Lifts the OCR seam out of StatementsModule into its own OcrModule so PolicyOcrModule can inject OCR_PROVIDER without taking on the rest of the statement pipeline; StatementsModule now imports it and binds nothing itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { PolicyForm } from "@/components/PolicyForm";
|
||||
import { PolicyOcrIntake } from "@/components/PolicyOcrIntake";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
|
||||
/**
|
||||
* Policy intake — mirror of `Captura.tsx` (statement OCR side): one screen,
|
||||
* two ways in:
|
||||
*
|
||||
* - **manual** — `PolicyForm` keys every field by hand.
|
||||
* - **auto** — `PolicyOcrIntake` uploads a GMX PDF, OCR proposes the
|
||||
* policy, a human still confirms.
|
||||
*
|
||||
* Both end at the same place (a `Policy` row on a customer's file) so they
|
||||
* live as two modes of one screen rather than two menu entries — exactly the
|
||||
* same shape Captura uses for `ManualCheckCapture` vs `StatementIntake`.
|
||||
*
|
||||
* `/polizas/nuevo` opens manual, `/polizas/captura` opens auto; both render
|
||||
* this component so the tab toggle works either way and an old bookmark
|
||||
* still lands on the right tab.
|
||||
*/
|
||||
export type PolicyCaptureMode = "manual" | "auto";
|
||||
|
||||
const MODE_HINT: Record<PolicyCaptureMode, string> = {
|
||||
manual:
|
||||
"Captura cada campo a mano. Use esta opción cuando la póliza llega en papel, en un correo sin PDF legible, o cuando hay que revisar cada dato.",
|
||||
auto: "Suelte el PDF descargado del portal de GMX y el sistema propondrá los campos. Nada se registra sin tu confirmación.",
|
||||
};
|
||||
|
||||
export function PolicyCaptura({ initialMode = "manual" }: { initialMode?: PolicyCaptureMode }) {
|
||||
const canCreate = useCan("policy:create");
|
||||
const canIngest = useCan("policy:ingest");
|
||||
|
||||
// `/clientes/[id]` deep-links into /polizas/nuevo with the customer
|
||||
// pre-picked so staff can fill the rest without retyping. The OCR pane
|
||||
// ignores these — there's no customer to lock in until the batch is
|
||||
// confirmed.
|
||||
const params = useSearchParams();
|
||||
const fixedCustomerId = params.get("customerId") ?? undefined;
|
||||
const fixedCustomerName = params.get("customerName") ?? undefined;
|
||||
|
||||
// One user can land on either mode. The tab strip only renders when both
|
||||
// abilities are held — a STAFF with only policy:ingest (no create) still
|
||||
// sees the screen but only the OCR tab is offered.
|
||||
const modes: { key: PolicyCaptureMode; label: string }[] = [
|
||||
...(canCreate ? [{ key: "manual" as const, label: "Captura manual" }] : []),
|
||||
...(canIngest ? [{ key: "auto" as const, label: "Captura automática (OCR)" }] : []),
|
||||
];
|
||||
|
||||
const [mode, setMode] = useState<PolicyCaptureMode>(
|
||||
modes.some((m) => m.key === initialMode) ? initialMode : (modes[0]?.key ?? "manual"),
|
||||
);
|
||||
|
||||
if (modes.length === 0) {
|
||||
return (
|
||||
<div className="state-box state-error">
|
||||
No tienes permiso para crear ni capturar pólizas.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<Link href="/polizas" className="back-link">← Pólizas</Link>
|
||||
<h1 className="page-title">Nueva póliza</h1>
|
||||
<p className="eyebrow">{MODE_HINT[mode]}</p>
|
||||
</div>
|
||||
|
||||
{modes.length > 1 && (
|
||||
<div className="seg" role="tablist" style={{ marginBottom: 16 }}>
|
||||
{modes.map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === m.key}
|
||||
className={`seg-btn ${mode === m.key ? "active" : ""}`}
|
||||
onClick={() => setMode(m.key)}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "manual" ? (
|
||||
<ManualPane
|
||||
fixedCustomerId={fixedCustomerId}
|
||||
fixedCustomerName={fixedCustomerName}
|
||||
/>
|
||||
) : (
|
||||
<PolicyOcrIntake />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualPane({
|
||||
fixedCustomerId,
|
||||
fixedCustomerName,
|
||||
}: {
|
||||
fixedCustomerId?: string;
|
||||
fixedCustomerName?: string;
|
||||
}) {
|
||||
const allowed = useCan("policy:create");
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="state-box state-error">
|
||||
No tiene permisos para crear pólizas.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PolicyForm
|
||||
fixedCustomerId={fixedCustomerId}
|
||||
fixedCustomerName={fixedCustomerName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
getPolicyOcrStatus,
|
||||
listPolicyOcrBatches,
|
||||
uploadPolicyOcrBatch,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate } from "@/lib/labels";
|
||||
import type { PolicyOcrBatch, PolicyOcrBatchStatus } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Insurance OCR intake — mirror of StatementIntake, scoped to the insurance
|
||||
* side. Today the only provider is GMX; the parser dispatches on a brand
|
||||
* wordmark (`Grupo Mexicano de Seguros` / `gmx.com.mx` / the GMX letterhead)
|
||||
* and a new portal only needs a new BRAND entry plus a parser file.
|
||||
*
|
||||
* Lives inside the `Pólizas` page rather than a top-level route because it
|
||||
* is one mode of one job (staff uploading whatever PDFs the office has on
|
||||
* hand that day, mixed service vs insurance), and the matching/review queue
|
||||
* already keys on the policyNumber → existing Policy transition that the
|
||||
* rest of /polizas owns.
|
||||
*/
|
||||
|
||||
const STATUS_LABEL: Record<PolicyOcrBatchStatus, string> = {
|
||||
UPLOADED: "Recibido",
|
||||
PROCESSING: "Procesando…",
|
||||
READY_FOR_REVIEW: "Listo para revisar",
|
||||
COMPLETED: "Aplicado",
|
||||
FAILED: "Falló",
|
||||
};
|
||||
|
||||
export function PolicyOcrIntake() {
|
||||
const canIngest = useCan("policy:ingest");
|
||||
const [batches, setBatches] = useState<PolicyOcrBatch[]>([]);
|
||||
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null);
|
||||
const [storageAvailable, setStorageAvailable] = useState<boolean | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [list, status] = await Promise.all([
|
||||
listPolicyOcrBatches(),
|
||||
getPolicyOcrStatus(),
|
||||
]);
|
||||
setBatches(list.items);
|
||||
setOcrAvailable(status.ocrAvailable);
|
||||
setStorageAvailable(status.storageAvailable);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudieron cargar los lotes.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const working = batches.some(
|
||||
(b) => b.status === "PROCESSING" || b.status === "UPLOADED",
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!working) return;
|
||||
const t = setInterval(() => void load(), 4000);
|
||||
return () => clearInterval(t);
|
||||
}, [working, load]);
|
||||
|
||||
const ready = ocrAvailable === true && storageAvailable === true;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{ocrAvailable === false && (
|
||||
<div className="state-box state-error">
|
||||
Este servidor no tiene OCR instalado, así que no se pueden leer PDFs
|
||||
de pólizas escaneados. La captura manual sigue funcionando.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{storageAvailable === false && (
|
||||
<div className="state-box state-error">
|
||||
Este servidor no tiene configurado el almacenamiento de documentos, así
|
||||
que no hay dónde guardar los PDFs. Mientras tanto, capture las
|
||||
pólizas a mano.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canIngest && ready && <UploadCard onDone={load} />}
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Lotes
|
||||
</h2>
|
||||
{loading ? (
|
||||
<div className="state-box">Cargando…</div>
|
||||
) : batches.length === 0 ? (
|
||||
<div className="state-box">
|
||||
Todavía no hay lotes de pólizas. Descargue el certificado del portal
|
||||
de GMX y suéltelo arriba.
|
||||
</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Aseguradora</th>
|
||||
<th>Referencia</th>
|
||||
<th>Estado</th>
|
||||
<th className="num">Páginas</th>
|
||||
<th>Subido por</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td style={{ whiteSpace: "nowrap" }}>{formatDate(b.createdAt)}</td>
|
||||
<td>{b.provider}</td>
|
||||
<td>{b.label || "—"}</td>
|
||||
<td>
|
||||
<StatusTag status={b.status} />
|
||||
{b.error && (
|
||||
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||
{b.error}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="num">{b._count?.documents ?? 0}</td>
|
||||
<td>{b.uploadedBy?.name ?? "—"}</td>
|
||||
<td>
|
||||
<Link
|
||||
className="btn btn-ghost"
|
||||
href={`/polizas/captura/${b.id}`}
|
||||
>
|
||||
Revisar
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTag({ status }: { status: PolicyOcrBatchStatus }) {
|
||||
return <span className="tag">{STATUS_LABEL[status] ?? status}</span>;
|
||||
}
|
||||
|
||||
function UploadCard({ onDone }: { onDone: () => void }) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [label, setLabel] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
if (!files.length) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await uploadPolicyOcrBatch(files, label.trim() || undefined);
|
||||
setFiles([]);
|
||||
setLabel("");
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo subir el lote.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Subir PDFs de pólizas (GMX)
|
||||
</h2>
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<span className="page-sub">Referencia (opcional)</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="ej. GMX julio 2026"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className="page-sub">Archivos PDF</span>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!files.length || busy}
|
||||
onClick={submit}
|
||||
>
|
||||
{busy ? "Subiendo…" : `Procesar ${files.length || ""}`.trim()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||
Un lote puede traer varios PDFs. Cada página se procesa por separado; el
|
||||
sistema busca una póliza existente por número y, si no la encuentra,
|
||||
propone crear una nueva bajo el cliente que se elija en la revisión.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import {
|
||||
confirmPolicyOcrBatch,
|
||||
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";
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="state-box">Cargando…</div>;
|
||||
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user