Files
jorgecuadros-platform/apps/web/src/app/polizas/page.tsx
T
rmancinasandClaude Opus 5 5e9cb12fba
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m43s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m0s
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>
2026-08-01 14:07:29 -07:00

501 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { ContextReports } from "@/components/ContextReports";
import { useCan } from "@/lib/abilities";
import {
EXPIRY_WINDOW_DAYS,
getPolicyFacets,
getPolicyStats,
listPolicies,
} from "@/lib/api";
import {
expiryPhrase,
formatDate,
formatMoney,
formatNumber,
policyStatusLabel,
premiumHeadline,
SIN_NOMBRE,
} from "@/lib/labels";
import type {
PolicyFacets,
PolicyListItem,
PolicyListResponse,
PolicySort,
PolicyStats,
PolicyStatus,
} from "@/lib/types";
type StatusFilter = "all" | PolicyStatus;
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
{ key: "all", label: "Todas" },
{ key: "expiring", label: "Por vencer" },
{ key: "active", label: "Vigentes" },
{ key: "expired", label: "Vencidas" },
{ key: "undated", label: "Sin vigencia" },
];
const SORTS: { key: PolicySort; label: string }[] = [
{ key: "expiry_desc", label: "Vencimiento (más reciente)" },
{ key: "expiry_asc", label: "Vencimiento (más próximo)" },
{ key: "customer", label: "Cliente (AZ)" },
{ key: "number", label: "Número de póliza" },
{ key: "premium_desc", label: "Prima (mayor a menor)" },
];
export default function PolizasPage() {
return (
<AppShell>
<PolizasBrowser />
</AppShell>
);
}
function PolizasBrowser() {
const canCreate = useCan("policy:create");
const canIngest = useCan("policy:ingest");
const [stats, setStats] = useState<PolicyStats | null>(null);
const [facets, setFacets] = useState<PolicyFacets | null>(null);
const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [typeId, setTypeId] = useState("");
const [providerId, setProviderId] = useState("");
const [sort, setSort] = useState<PolicySort>("expiry_desc");
const [data, setData] = useState<PolicyListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
getPolicyStats().then(setStats).catch(() => setStats(null));
getPolicyFacets().then(setFacets).catch(() => setFacets(null));
}, []);
const runSearch = useCallback(
(p: number) => {
setLoading(true);
setError(null);
listPolicies({
query: query || undefined,
status: status === "all" ? undefined : status,
typeId: typeId || undefined,
providerId: providerId || undefined,
sort,
days: EXPIRY_WINDOW_DAYS,
page: p,
pageSize: 25,
})
.then((res) => {
setData(res);
setLoading(false);
})
.catch((e) => {
setError(e?.message ?? "No se pudieron cargar las pólizas.");
setLoading(false);
});
},
[query, status, typeId, providerId, sort],
);
// Debounced re-query whenever any filter changes; always back to page 1.
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => runSearch(1), 280);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [runSearch]);
function goToPage(p: number) {
runSearch(p);
if (typeof window !== "undefined")
window.scrollTo({ top: 0, behavior: "smooth" });
}
const filtered =
query !== "" || status !== "all" || typeId !== "" || providerId !== "";
return (
<>
<div className="page-head rise">
<p className="eyebrow">Cartera de seguros</p>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<h1 className="page-title" style={{ margin: 0 }}>Pólizas</h1>
<span style={{ flex: 1 }} />
<ContextReports
entries={[
{ slug: "vigente", label: "Por vencer (Lic.)", params: { typeName: "LICENCIAS" } },
{ slug: "vigente", label: "Por vencer (Mult.)", params: { typeName: "MULT" } },
{ slug: "vigente", label: "Por vencer (Incen.)", params: { typeName: "INCEN" } },
]}
/>
{canIngest && (
<Link href="/polizas/captura" className="btn btn-outline">
+ Captura OCR
</Link>
)}
{canCreate && (
<Link href="/polizas/nuevo" className="btn btn-primary">+ Nueva póliza</Link>
)}
</div>
<StatStrip
stats={stats}
status={status}
onPickStatus={(s) => setStatus(s)}
/>
<PremiumStrip stats={stats} />
</div>
<div className="toolbar">
<div className="search-box">
<span className="search-icon" aria-hidden>
</span>
<input
className="input search-input"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar por póliza, cliente, placa, agente…"
aria-label="Buscar pólizas"
/>
</div>
<div className="seg" role="tablist" aria-label="Filtrar por vigencia">
{STATUS_FILTERS.map((f) => (
<button
key={f.key}
type="button"
role="tab"
aria-selected={status === f.key}
className={`seg-btn ${status === f.key ? "active" : ""}`}
onClick={() => setStatus(f.key)}
>
{f.label}
</button>
))}
</div>
</div>
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Ramo</span>
<select
className="input select"
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
>
<option value="">Todos los ramos</option>
{facets?.types
.filter((t) => t.count > 0)
.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({formatNumber(t.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Aseguradora</span>
<select
className="input select"
value={providerId}
onChange={(e) => setProviderId(e.target.value)}
>
<option value="">Todas las aseguradoras</option>
{facets?.providers
.filter((p) => p.count > 0)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({formatNumber(p.count)})
</option>
))}
</select>
</label>
<label className="filter-field">
<span className="filter-label">Ordenar por</span>
<select
className="input select"
value={sort}
onChange={(e) => setSort(e.target.value as PolicySort)}
>
{SORTS.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</label>
{filtered && (
<button
type="button"
className="btn btn-ghost filter-clear"
onClick={() => {
setQuery("");
setStatus("all");
setTypeId("");
setProviderId("");
}}
>
Limpiar filtros
</button>
)}
</div>
{data && !loading && !error && (
<div className="result-meta" aria-live="polite">
{data.total === 0
? "Sin resultados"
: `${formatNumber(data.total)} ${
data.total === 1 ? "póliza" : "pólizas"
}`}
{query ? ` para “${query}”` : ""}
</div>
)}
{error ? (
<div className="state-error" role="alert">
{error}
</div>
) : loading ? (
<ListSkeleton />
) : data && data.items.length === 0 ? (
<EmptyState query={query} />
) : (
<>
<div className="cust-list">
{data?.items.map((p) => (
<PolicyRow key={p.id} p={p} />
))}
</div>
{data && data.pageCount > 1 && (
<Pager
page={data.page}
pageCount={data.pageCount}
onChange={goToPage}
/>
)}
</>
)}
</>
);
}
/** Counts double as filter shortcuts — clicking a cell applies that bucket. */
function StatStrip({
stats,
status,
onPickStatus,
}: {
stats: PolicyStats | null;
status: StatusFilter;
onPickStatus: (s: StatusFilter) => void;
}) {
if (!stats) {
return (
<div className="stat-strip" aria-hidden>
{Array.from({ length: 6 }).map((_, i) => (
<div className="stat-cell" key={i}>
<div className="skeleton" style={{ height: 25, width: "60%" }} />
<div
className="skeleton"
style={{ height: 11, width: "80%", marginTop: 8 }}
/>
</div>
))}
</div>
);
}
const cells: {
key: StatusFilter;
value: number;
label: string;
accent?: boolean;
}[] = [
{ key: "all", value: stats.total, label: "Pólizas", accent: true },
{
key: "expiring",
value: stats.expiring,
label: `Vencen en ${stats.days} días`,
accent: true,
},
{ key: "active", value: stats.active, label: "Vigentes" },
{ key: "expired", value: stats.expired, label: "Vencidas" },
{ key: "undated", value: stats.undated, label: "Sin vigencia" },
];
return (
<div className="stat-strip">
{cells.map((c) => (
<button
type="button"
key={c.label}
className={`stat-cell stat-cell-btn${c.accent ? " accent" : ""}${
status === c.key ? " selected" : ""
}`}
onClick={() => onPickStatus(c.key)}
aria-pressed={status === c.key}
>
<div className="stat-value">{formatNumber(c.value)}</div>
<div className="stat-label">{c.label}</div>
</button>
))}
<div className="stat-cell">
<div className="stat-value">{formatNumber(stats.pending)}</div>
<div className="stat-label">Sin liquidar</div>
</div>
</div>
);
}
/** Premium in force, split by currency — MXN and USD can't be summed. */
function PremiumStrip({ stats }: { stats: PolicyStats | null }) {
if (!stats || stats.premiumInForce.length === 0) return null;
return (
<div className="premium-strip">
<span className="premium-caption">Prima neta vigente</span>
{stats.premiumInForce.map((row) => (
<span className="premium-chip" key={row.currency}>
<strong>{formatMoney(row.netPremium, row.currency)}</strong>
<span className="premium-chip-sub">
{row.currency} · {formatNumber(row.count)}{" "}
{row.count === 1 ? "póliza" : "pólizas"}
</span>
</span>
))}
</div>
);
}
function PolicyRow({ p }: { p: PolicyListItem }) {
const premium = premiumHeadline(p);
const phrase = expiryPhrase(p.daysToExpiry);
const showPhrase = p.status === "expiring" || p.status === "active";
return (
<Link href={`/polizas/${p.id}`} className="cust-row pol-row">
<div className="cust-main">
<div className="cust-name">
<span className="mono pol-number">{p.policyNumber || "—"}</span>
{p.policyType?.name && (
<span className="badge badge-seguros">
<span className="dot" /> {p.policyType.name}
</span>
)}
<span className={`badge status-${p.status}`}>
{policyStatusLabel(p.status)}
</span>
{!p.liquidated && (
<span className="badge badge-neutral">Sin liquidar</span>
)}
</div>
<div className="cust-sub">
<span
className={
p.customerName === SIN_NOMBRE ? "cust-name-missing" : undefined
}
>
{p.customerName}
</span>
{p.insuranceProvider?.name && (
<>
<span className="sep">·</span>
<span>{p.insuranceProvider.name}</span>
</>
)}
{p.vehicleCount > 0 && (
<>
<span className="sep">·</span>
<span>
{p.vehicleCount}{" "}
{p.vehicleCount === 1 ? "vehículo" : "vehículos"}
</span>
</>
)}
</div>
</div>
<div className="pol-side">
<div className="pol-premium">
{formatMoney(premium.value, p.currency)}
</div>
<div className="pol-dates mono">
{formatDate(p.policyFrom)} {formatDate(p.policyTo)}
</div>
{showPhrase && phrase && (
<div className={`pol-phrase ${p.status}`}>{phrase}</div>
)}
</div>
</Link>
);
}
function Pager({
page,
pageCount,
onChange,
}: {
page: number;
pageCount: number;
onChange: (p: number) => void;
}) {
return (
<nav className="pager" aria-label="Paginación">
<button
type="button"
className="btn btn-outline"
onClick={() => onChange(page - 1)}
disabled={page <= 1}
>
Anterior
</button>
<span className="pager-info">
Página <strong>{page}</strong> de {pageCount}
</span>
<button
type="button"
className="btn btn-outline"
onClick={() => onChange(page + 1)}
disabled={page >= pageCount}
>
Siguiente
</button>
</nav>
);
}
function ListSkeleton() {
return (
<div className="cust-list" aria-hidden>
{Array.from({ length: 8 }).map((_, i) => (
<div className="skeleton skel-row" key={i} />
))}
</div>
);
}
function EmptyState({ query }: { query: string }) {
return (
<div className="state-box">
<div className="state-glyph" aria-hidden>
</div>
<h3>Sin resultados</h3>
<p>
{query
? `No encontramos pólizas para “${query}”.`
: "No hay pólizas que coincidan con los filtros."}
</p>
</div>
);
}