diff --git a/RESUME.md b/RESUME.md index d4d93bb..d881dcd 100644 --- a/RESUME.md +++ b/RESUME.md @@ -204,8 +204,21 @@ Homebrew. No Access ODBC driver, `node_modules` not installed, staging Parquet n doc_1/doc_2), but only 3 cells populated across 1520 rows; the *_MENS tables are mail-merge templates (correctly excluded). The 538MB/882MB source files are mostly Access bloat, not documents. **Migration steps 1-4 COMPLETE.** - - **NEXT:** the Customer module API/web (Spanish-first) — all relational data + documents are - loaded, so build the unified customer list/search/detail view. + - **Customer module (plan step 3) — DONE**: `apps/api/src/customers/` (list/search/detail/stats) + + `apps/web` `/clientes` and `/clientes/[id]`, Spanish-first, verified against real data. + - **Insurance module (plan step 4) — DONE**: `apps/api/src/policies/` (`GET /policies` with + search over policy number / customer / agent / plate / driver name, vigencia buckets + active|expiring|expired|undated, ramo + aseguradora + liquidada filters, 5 sorts; + `/policies/stats`, `/policies/facets`, `/policies/:id`) + web `/polizas` (renewals-first + browser, clickable stat cells) and `/polizas/[id]`. Cross-links both ways with the customer + view. **Data finding:** the `policies.total` column is dead — only 2 of 2378 rows are + non-zero (1585 are literally 0, 791 null) and one of those two is *lower* than its own net + premium, so every premium headline and the premium sort use `netPremium` (populated on + 2377/2378). This also fixed a live bug on the customer detail page, which was showing + "$0.00 Total" for 1585 policies. + - **NEXT:** plan step 5 — Utilities module (properties / services / trust accounts as a + first-class browser, the way `/polizas` now is for insurance), then step 6, the shared + billing/statements view. - Full pipeline reproducible in one command: `run_all.py --env ` runs customers -> properties -> policies -> transactions -> bank in order (all idempotent); add `--stage` to re-extract from the Access files first. Verified end-to-end against dev. diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5871e28..c0c760f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,6 +4,7 @@ import { PrismaModule } from "./prisma/prisma.module"; import { UsersModule } from "./users/users.module"; import { AuthModule } from "./auth/auth.module"; import { CustomersModule } from "./customers/customers.module"; +import { PoliciesModule } from "./policies/policies.module"; import { AppController } from "./app.controller"; @Module({ @@ -13,6 +14,7 @@ import { AppController } from "./app.controller"; UsersModule, AuthModule, CustomersModule, + PoliciesModule, ], controllers: [AppController], }) diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts new file mode 100644 index 0000000..31a3d38 --- /dev/null +++ b/apps/api/src/policies/policies.controller.ts @@ -0,0 +1,72 @@ +import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common"; +import { AuthenticatedGuard } from "../auth/authenticated.guard"; +import { + PoliciesService, + type PolicySort, + type PolicyStatus, +} from "./policies.service"; + +const STATUSES: PolicyStatus[] = ["active", "expiring", "expired", "undated"]; +const SORTS: PolicySort[] = [ + "expiry_desc", + "expiry_asc", + "customer", + "number", + "premium_desc", +]; + +/** Clamped expiry window; 30 days is the default renewal horizon. */ +function parseDays(days?: string): number { + return Math.min(365, Math.max(1, Number(days) || 30)); +} + +@UseGuards(AuthenticatedGuard) +@Controller("policies") +export class PoliciesController { + constructor(private readonly policies: PoliciesService) {} + + @Get("stats") + stats(@Query("days") days?: string) { + return this.policies.stats(parseDays(days)); + } + + @Get("facets") + facets() { + return this.policies.facets(); + } + + @Get() + list( + @Query("query") query?: string, + @Query("page") page?: string, + @Query("pageSize") pageSize?: string, + @Query("status") status?: string, + @Query("days") days?: string, + @Query("typeId") typeId?: string, + @Query("providerId") providerId?: string, + @Query("liquidated") liquidated?: string, + @Query("sort") sort?: string, + ) { + return this.policies.list({ + query, + page: Math.max(1, Number(page) || 1), + pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)), + status: STATUSES.includes(status as PolicyStatus) + ? (status as PolicyStatus) + : undefined, + days: parseDays(days), + typeId: typeId || undefined, + providerId: providerId || undefined, + liquidated: + liquidated === "true" ? true : liquidated === "false" ? false : undefined, + sort: SORTS.includes(sort as PolicySort) + ? (sort as PolicySort) + : "expiry_desc", + }); + } + + @Get(":id") + detail(@Param("id") id: string, @Query("days") days?: string) { + return this.policies.detail(id, parseDays(days)); + } +} diff --git a/apps/api/src/policies/policies.module.ts b/apps/api/src/policies/policies.module.ts new file mode 100644 index 0000000..f6ce42c --- /dev/null +++ b/apps/api/src/policies/policies.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { PoliciesController } from "./policies.controller"; +import { PoliciesService } from "./policies.service"; + +@Module({ + controllers: [PoliciesController], + providers: [PoliciesService], +}) +export class PoliciesModule {} diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts new file mode 100644 index 0000000..a18e213 --- /dev/null +++ b/apps/api/src/policies/policies.service.ts @@ -0,0 +1,281 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma } from "@jorgecuadros/database"; +import { PrismaService } from "../prisma/prisma.service"; + +/** + * Vigencia buckets, derived from `policyTo` against today. `undated` is a real + * bucket rather than an error case: 528 of the migrated policies carry no end + * date at all (the legacy Access tables left it blank), so they can neither be + * called current nor expired. + */ +export type PolicyStatus = "active" | "expiring" | "expired" | "undated"; + +export type PolicySort = + | "expiry_desc" + | "expiry_asc" + | "customer" + | "number" + | "premium_desc"; + +export interface ListParams { + query?: string; + page: number; + pageSize: number; + status?: PolicyStatus; + /** Window in days for the `expiring` bucket. */ + days: number; + typeId?: string; + providerId?: string; + liquidated?: boolean; + sort: PolicySort; +} + +/** Midnight today, UTC — policy dates are stored date-only at 00:00 UTC. */ +function today(): Date { + const now = new Date(); + return new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); +} + +function addDays(d: Date, days: number): Date { + return new Date(d.getTime() + days * 86400000); +} + +function statusOf(policyTo: Date | null, from: Date, soon: Date): PolicyStatus { + if (!policyTo) return "undated"; + if (policyTo < from) return "expired"; + return policyTo <= soon ? "expiring" : "active"; +} + +function daysUntil(policyTo: Date | null, from: Date): number | null { + if (!policyTo) return null; + return Math.round((policyTo.getTime() - from.getTime()) / 86400000); +} + +@Injectable() +export class PoliciesService { + constructor(private readonly prisma: PrismaService) {} + + private statusWhere( + status: PolicyStatus | undefined, + days: number, + ): Prisma.PolicyWhereInput { + const from = today(); + switch (status) { + case "active": + return { policyTo: { gte: from } }; + case "expiring": + return { policyTo: { gte: from, lte: addDays(from, days) } }; + case "expired": + return { policyTo: { lt: from } }; + case "undated": + return { policyTo: null }; + default: + return {}; + } + } + + private orderBy(sort: PolicySort): Prisma.PolicyOrderByWithRelationInput[] { + switch (sort) { + case "expiry_asc": + return [{ policyTo: "asc" }]; + case "customer": + return [{ customer: { name: "asc" } }, { policyTo: "desc" }]; + case "number": + return [{ policyNumber: "asc" }]; + case "premium_desc": + // Sorts on netPremium, not total: `total` is 0 or null on all but 2 of + // the 2378 migrated policies, so ordering by it is meaningless. + return [{ netPremium: "desc" }]; + default: + // MySQL sorts NULLs last on DESC, which puts the 528 undated policies + // at the end instead of the top — the behaviour we want by default. + return [{ policyTo: "desc" }]; + } + } + + /** Policy list with search, vigencia/type/provider filters, paginated. */ + async list(params: ListParams) { + const { query, page, pageSize, status, days, typeId, providerId, liquidated, sort } = + params; + + const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) }; + + if (query && query.trim()) { + const q = query.trim(); + where.OR = [ + { policyNumber: { contains: q } }, + { customer: { name: { contains: q } } }, + { agentName: { contains: q } }, + { vehicles: { some: { licensePlate: { contains: q } } } }, + { insuredDrivers: { some: { fullName: { contains: q } } } }, + { legacyId: { contains: q } }, + ]; + } + if (typeId) where.policyTypeId = typeId; + if (providerId) where.insuranceProviderId = providerId; + if (liquidated !== undefined) where.liquidated = liquidated; + + const [total, rows] = await this.prisma.$transaction([ + this.prisma.policy.count({ where }), + this.prisma.policy.findMany({ + where, + skip: (page - 1) * pageSize, + take: pageSize, + orderBy: this.orderBy(sort), + select: { + id: true, + policyNumber: true, + agentName: true, + policyFrom: true, + policyTo: true, + netPremium: true, + total: true, + currency: true, + liquidated: true, + customer: { select: { id: true, name: true, city: true } }, + policyType: { select: { id: true, name: true } }, + insuranceProvider: { select: { id: true, name: true } }, + _count: { select: { vehicles: true, installments: true, documents: true } }, + }, + }), + ]); + + const from = today(); + const soon = addDays(from, days); + + const items = rows.map((r) => ({ + id: r.id, + policyNumber: r.policyNumber, + agentName: r.agentName, + policyFrom: r.policyFrom, + policyTo: r.policyTo, + netPremium: r.netPremium, + total: r.total, + currency: r.currency, + liquidated: r.liquidated, + customerId: r.customer.id, + customerName: r.customer.name, + customerCity: r.customer.city, + policyType: r.policyType, + insuranceProvider: r.insuranceProvider, + status: statusOf(r.policyTo, from, soon), + daysToExpiry: daysUntil(r.policyTo, from), + vehicleCount: r._count.vehicles, + installmentCount: r._count.installments, + documentCount: r._count.documents, + })); + + return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) }; + } + + /** Top-line counts for the policies page header. */ + async stats(days: number) { + const from = today(); + const soon = addDays(from, days); + + const [total, active, expiring, expired, undated, liquidated] = + await this.prisma.$transaction([ + this.prisma.policy.count(), + this.prisma.policy.count({ where: { policyTo: { gte: from } } }), + this.prisma.policy.count({ + where: { policyTo: { gte: from, lte: soon } }, + }), + this.prisma.policy.count({ where: { policyTo: { lt: from } } }), + this.prisma.policy.count({ where: { policyTo: null } }), + this.prisma.policy.count({ where: { liquidated: true } }), + ]); + + // Premium in force, per currency — the two currencies can't be summed. + const inForce = await this.prisma.policy.groupBy({ + by: ["currency"], + where: { policyTo: { gte: from } }, + _sum: { total: true, netPremium: true }, + _count: { _all: true }, + }); + + return { + total, + active, + expiring, + expired, + undated, + liquidated, + pending: total - liquidated, + days, + premiumInForce: inForce.map((r) => ({ + currency: r.currency, + total: r._sum.total, + netPremium: r._sum.netPremium, + count: r._count._all, + })), + }; + } + + /** Filter dropdown options, with counts so empty choices are visible. */ + async facets() { + const [types, providers] = await this.prisma.$transaction([ + this.prisma.policyType.findMany({ + orderBy: { name: "asc" }, + select: { id: true, name: true, _count: { select: { policies: true } } }, + }), + this.prisma.insuranceProvider.findMany({ + orderBy: { name: "asc" }, + select: { id: true, name: true, _count: { select: { policies: true } } }, + }), + ]); + + return { + types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })), + providers: providers.map((p) => ({ + id: p.id, + name: p.name, + count: p._count.policies, + })), + }; + } + + /** Full policy view, including the owning customer. */ + async detail(id: string, days: number) { + const policy = await this.prisma.policy.findUnique({ + where: { id }, + include: { + customer: { + select: { + id: true, + name: true, + nameSource: true, + city: true, + state: true, + phone: true, + mobile: true, + email: true, + }, + }, + policyType: true, + insuranceProvider: true, + installments: { orderBy: { sequence: "asc" } }, + vehicles: true, + insuredDrivers: true, + beneficiaries: true, + claims: { include: { adjuster: true } }, + documents: true, + properties: { + select: { id: true, addressLine1: true, addressLine2: true, zone: true }, + }, + }, + }); + + if (!policy) { + throw new NotFoundException(`Policy ${id} not found`); + } + + const from = today(); + return { + ...policy, + status: statusOf(policy.policyTo, from, addDays(from, days)), + daysToExpiry: daysUntil(policy.policyTo, from), + }; + } +} diff --git a/apps/web/src/app/clientes/[id]/page.tsx b/apps/web/src/app/clientes/[id]/page.tsx index 2e752e6..780405f 100644 --- a/apps/web/src/app/clientes/[id]/page.tsx +++ b/apps/web/src/app/clientes/[id]/page.tsx @@ -8,6 +8,7 @@ import { domainLabel, formatDate, formatMoney, + premiumHeadline, serviceKindGlyph, serviceKindLabel, SIN_NOMBRE, @@ -409,14 +410,15 @@ function PolizasSection({ policies }: { policies: Policy[] }) { } function PolicyCard({ p }: { p: Policy }) { - const headline = p.total ?? p.netPremium; - const headlineLabel = p.total ? "Total" : "Prima neta"; + const { value: headline, label: headlineLabel } = premiumHeadline(p); return (
-
{p.policyNumber || "—"}
+ + {p.policyNumber || "—"} → +
{p.policyType?.name && ( @@ -454,11 +456,6 @@ function PolicyCard({ p }: { p: Policy }) { {formatMoney(headline, p.currency)}
{headlineLabel}
- {p.total && p.netPremium && ( -
- Prima neta {formatMoney(p.netPremium, p.currency)} -
- )}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 1bd73e8..765fe7b 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1444,3 +1444,243 @@ button { color: var(--muted); margin-top: 10px; } + +/* ============================================================================ + Policies — vigencia badges + ========================================================================== */ +.badge.status-active { + background: var(--positive-tint); + color: var(--positive); + border-color: rgba(47, 109, 60, 0.25); +} +.badge.status-expiring { + background: var(--accent-tint); + color: var(--accent-600); + border-color: rgba(191, 90, 52, 0.28); +} +.badge.status-expired { + background: var(--negative-tint); + color: var(--negative); + border-color: rgba(162, 58, 44, 0.25); +} +.badge.status-undated { + background: var(--paper-2); + color: var(--muted); + border-color: var(--line-strong); +} + +/* ============================================================================ + Policies — header strips + ========================================================================== */ +/* Stat cells double as vigencia filters on /polizas. */ +.stat-cell-btn { + display: block; + width: 100%; + text-align: left; + border: none; + cursor: pointer; + font: inherit; + transition: background 0.15s; +} +.stat-cell-btn:hover { + background: var(--surface-2); +} +.stat-cell-btn.accent:hover { + background: #d7e6e1; +} +.stat-cell-btn.selected { + box-shadow: inset 0 -3px 0 var(--brand-600); +} + +.premium-strip { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + margin-top: 14px; + padding: 0 2px; +} +.premium-caption { + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; + color: var(--muted); +} +.premium-chip { + display: inline-flex; + align-items: baseline; + gap: 8px; +} +.premium-chip strong { + font-family: var(--font-mono); + font-feature-settings: "tnum" 1; + font-size: 15px; + color: var(--brand-700); +} +.premium-chip-sub { + font-size: 12px; + color: var(--muted); +} + +/* ============================================================================ + Policies — secondary filter row + ========================================================================== */ +.filter-row { + display: flex; + gap: 14px; + align-items: flex-end; + flex-wrap: wrap; + margin-bottom: 18px; +} +.filter-field { + display: flex; + flex-direction: column; + gap: 5px; + flex: 1 1 210px; + min-width: 0; +} +.filter-label { + font-size: 11.5px; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; + color: var(--muted); +} +.select { + appearance: none; + cursor: pointer; + padding-right: 34px; + background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), + linear-gradient(135deg, var(--muted) 50%, transparent 50%); + background-position: calc(100% - 18px) 55%, calc(100% - 13px) 55%; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; +} +.filter-clear { + flex: 0 0 auto; + height: 40px; +} + +/* ============================================================================ + Policies — list rows + ========================================================================== */ +.pol-row .cust-name { + flex-wrap: wrap; + gap: 9px; +} +.pol-number { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; +} +.pol-side { + text-align: right; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + flex: 0 0 auto; +} +.pol-premium { + font-family: var(--font-mono); + font-feature-settings: "tnum" 1; + font-size: 15px; + font-weight: 600; + color: var(--ink); +} +.pol-dates { + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} +.pol-phrase { + font-size: 11.5px; + font-weight: 600; +} +.pol-phrase.expiring { + color: var(--accent-600); +} +.pol-phrase.active { + color: var(--positive); +} +@media (max-width: 620px) { + .pol-side { + align-items: flex-start; + text-align: left; + margin-top: 8px; + } +} + +/* ============================================================================ + Policy detail — owner card, linked properties, vehicles + ========================================================================== */ +.owner-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 22px; + color: inherit; + text-decoration: none; + border-radius: var(--radius-lg); + transition: background 0.15s; +} +.owner-link:hover { + background: var(--surface-2); +} +.owner-name { + font-family: var(--font-display); + font-size: 19px; + font-weight: 560; + margin-bottom: 3px; +} +.owner-cta { + font-size: 13px; + font-weight: 600; + color: var(--brand-600); + white-space: nowrap; +} +.linked-props { + border-top: 1px solid var(--line); + padding: 16px 22px 18px; +} +.linked-prop { + font-size: 14px; + margin-top: 4px; +} + +.veh-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + padding: 16px; +} +.veh-card { + background: var(--surface-2); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 13px 15px; +} +.veh-title { + font-weight: 600; + font-size: 14.5px; + margin-bottom: 6px; +} +.veh-facts { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 12.5px; + color: var(--muted); +} + +/* Policy number on the customer detail card links through to /polizas/[id]. */ +.policy-num-link { + color: inherit; + text-decoration: none; + transition: color 0.15s; +} +.policy-num-link:hover { + color: var(--brand-600); +} diff --git a/apps/web/src/app/polizas/[id]/page.tsx b/apps/web/src/app/polizas/[id]/page.tsx new file mode 100644 index 0000000..cc2bf3a --- /dev/null +++ b/apps/web/src/app/polizas/[id]/page.tsx @@ -0,0 +1,588 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { AppShell } from "@/components/AppShell"; +import { getPolicy } from "@/lib/api"; +import { + expiryPhrase, + formatDate, + formatMoney, + policyStatusLabel, + premiumHeadline, + SIN_NOMBRE, +} from "@/lib/labels"; +import type { Installment, PolicyDetail } from "@/lib/types"; + +export default function PolizaDetailPage({ + params, +}: { + params: { id: string }; +}) { + // Next 14 passes `params` as a plain object here — no `use()` unwrapping. + const { id } = params; + return ( + + + + ); +} + +function Detail({ id }: { id: string }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let alive = true; + setLoading(true); + setError(null); + getPolicy(id) + .then((d) => { + if (alive) { + setData(d); + setLoading(false); + } + }) + .catch((e) => { + if (alive) { + setError( + e?.status === 404 + ? "No encontramos esta póliza." + : e?.message ?? "No se pudo cargar la póliza.", + ); + setLoading(false); + } + }); + return () => { + alive = false; + }; + }, [id]); + + if (loading) return ; + + if (error) + return ( + <> + +
+ {error} +
+ + ); + + if (!data) return null; + + return ( +
+ + + + + {data.installments.length > 0 && } + {data.vehicles.length > 0 && } + {(data.insuredDrivers.length > 0 || data.beneficiaries.length > 0) && ( + + )} + {data.claims.length > 0 && } + + +
+ ); +} + +function BackLink() { + return ( + + ← Volver a Pólizas + + ); +} + +/* ------------------------------------------------------------------ Hero */ +function Hero({ data }: { data: PolicyDetail }) { + const premium = premiumHeadline(data); + const phrase = expiryPhrase(data.daysToExpiry); + const provenance = [data.legacySourceTable, data.legacyId] + .filter(Boolean) + .join(" #"); + + const facts: { label: string; value: string }[] = [ + { label: "Vigencia desde", value: formatDate(data.policyFrom) }, + { label: "Vigencia hasta", value: formatDate(data.policyTo) }, + { label: premium.label, value: formatMoney(premium.value, data.currency) }, + { label: "Moneda", value: data.currency ?? "—" }, + { label: "Agente", value: data.agentName || "—" }, + ]; + + return ( +
+
+
+

{data.policyNumber || "—"}

+
+ {[data.policyType?.name, data.insuranceProvider?.name] + .filter(Boolean) + .join(" · ") || "Sin ramo ni aseguradora registrados"} +
+ {provenance && ( +
Origen: {provenance}
+ )} +
+
+ + {policyStatusLabel(data.status)} + {phrase && data.status !== "expired" ? ` · ${phrase}` : ""} + + + {data.liquidated ? "Liquidada" : "Sin liquidar"} + + {data.endorsement && ( + Endoso + )} +
+
+
+ {facts.map((f) => ( +
+
{f.label}
+
{f.value}
+
+ ))} +
+
+ ); +} + +/* -------------------------------------------------------------- Cliente */ +function ClienteSection({ data }: { data: PolicyDetail }) { + const c = data.customer; + const location = [c.city?.replace(/,\s*$/, ""), c.state] + .filter(Boolean) + .join(", "); + + return ( +
+ +
+ +
+
+ {c.name} +
+
+ {location && {location}} + {location && (c.phone || c.email) && ( + · + )} + {(c.phone || c.mobile) && {c.phone || c.mobile}} + {c.email && ( + <> + · + {c.email} + + )} +
+
+ Ver expediente → + + + {data.properties.length > 0 && ( +
+
Propiedades cubiertas
+ {data.properties.map((p) => ( +
+ {[p.addressLine1, p.addressLine2].filter(Boolean).join(", ") || + "Propiedad"} + {p.zone && · Zona {p.zone}} +
+ ))} +
+ )} +
+
+ ); +} + +/* ---------------------------------------------------------- Condiciones */ +function CondicionesSection({ data }: { data: PolicyDetail }) { + const cur = data.currency; + return ( +
+ +
+
+ + + + + + + {/* The legacy `total` is 0 or null on all but 2 of 2378 policies — + only show it when it actually carries a figure. */} + {data.total != null && Number(data.total) > 0 && ( + + )} + + {data.observations && ( +
+
Observaciones
+
{data.observations}
+
+ )} + {data.notes && ( +
+
Notas
+
{data.notes}
+
+ )} +
+
+
+ ); +} + +/* --------------------------------------------------------------- Pagos */ +function PagosSection({ data }: { data: PolicyDetail }) { + const paid = data.installments.filter((i) => i.paidDate).length; + return ( +
+ +
+
+ {data.installments.map((inst) => ( + + ))} +
+
+
+ ); +} + +function InstallmentRow({ inst }: { inst: Installment }) { + const method = inst.isCash + ? "Efectivo" + : inst.checkNumber + ? `Ref. ${inst.checkNumber}` + : null; + return ( +
+ + {inst.sequence} + + {inst.paidDate ? formatDate(inst.paidDate) : "Sin pagar"} + {inst.dueDate && !inst.paidDate && ( + + {" "} + · vence {formatDate(inst.dueDate)} + + )} + {method && ( + + {" "} + · {method} + + )} + + + + {formatMoney(inst.amount, inst.currency)} + +
+ ); +} + +/* ----------------------------------------------------------- Vehículos */ +function VehiculosSection({ data }: { data: PolicyDetail }) { + return ( +
+ +
+
+ {data.vehicles.map((v) => ( +
+
+ {[v.make, v.model, v.modelYear].filter(Boolean).join(" ") || + "Vehículo"} +
+
+ {v.bodyType && {v.bodyType}} + {v.licensePlate && ( + + Placa: {v.licensePlate} + + )} + {v.vinNumber && ( + + Serie: {v.vinNumber} + + )} + {v.engineNumber && ( + + Motor: {v.engineNumber} + + )} +
+
+ ))} +
+
+
+ ); +} + +/* ------------------------------------------- Asegurados y beneficiarios */ +function PersonasSection({ data }: { data: PolicyDetail }) { + return ( +
+ +
+
+ {data.insuredDrivers.length > 0 && ( +
+
+ Asegurados + {data.insuredDrivers.length} +
+
+ {data.insuredDrivers.map((d) => ( +
+ {d.fullName || "—"} + {d.licenseNumber && ( +
Lic. {d.licenseNumber}
+ )} +
+ ))} +
+
+ )} + {data.beneficiaries.length > 0 && ( +
+
+ Beneficiarios + {data.beneficiaries.length} +
+
+ {data.beneficiaries.map((b) => ( +
+ {b.name || "—"} + {(b.phone || b.email) && ( +
+ {[b.phone, b.email].filter(Boolean).join(" · ")} +
+ )} +
+ ))} +
+
+ )} +
+
+
+ ); +} + +/* --------------------------------------------------------- Siniestros */ +function SiniestrosSection({ data }: { data: PolicyDetail }) { + return ( +
+ +
+ {data.claims.map((c) => ( +
+
{c.claimType || "Siniestro"}
+
+ {c.incidentDate && ( + Ocurrido: {formatDate(c.incidentDate)} + )} + {c.reportedDate && ( + Reportado: {formatDate(c.reportedDate)} + )} + {c.adjuster?.name && Ajustador: {c.adjuster.name}} +
+
+ + + + {c.description && ( +
+
Descripción
+
{c.description}
+
+ )} +
+
+ ))} +
+
+ ); +} + +/* -------------------------------------------------------- Coberturas */ +/** The legacy tables carry per-line coverage columns the target schema does + * not model; the migration preserved them verbatim in `coveragesJson`. */ +function CoberturasSection({ data }: { data: PolicyDetail }) { + const entries = Object.entries(data.coveragesJson ?? {}).filter( + ([, v]) => v !== null && v !== "" && v !== 0, + ); + if (entries.length === 0) return null; + + return ( +
+ +
+
+ {entries.map(([k, v]) => ( +
+
{k}
+
{String(v)}
+
+ ))} +
+
+ Campos de cobertura conservados tal cual desde el sistema anterior. +
+
+
+ ); +} + +/* -------------------------------------------------------- Documentos */ +function DocumentosSection({ data }: { data: PolicyDetail }) { + return ( +
+ +
+ {data.documents.length === 0 ? ( +
+ No hay documentos registrados para esta póliza. +
+ ) : ( + <> +
+ {data.documents.map((d, i) => ( +
+ + ▤ + +
+
{d.documentType || "Documento"}
+
{d.storageKey || "—"}
+
+
+ ))} +
+
+ Los archivos se almacenan en el object storage (storageKey); no se + descargan desde esta vista. +
+ + )} +
+
+ ); +} + +/* ------------------------------------------------------------ helpers */ +function KV({ + label, + value, +}: { + label: string; + value: string | null | undefined; +}) { + return ( +
+
{label}
+
{value || "—"}
+
+ ); +} + +function SectionHead({ + rule, + title, + count, + countSuffix, +}: { + rule: string; + title: string; + count?: number; + countSuffix?: string; +}) { + return ( +
+ +

{title}

+ {count != null && ( + + {count} {countSuffix ?? ""} + + )} +
+ ); +} + +function DetailSkeleton() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/polizas/page.tsx b/apps/web/src/app/polizas/page.tsx new file mode 100644 index 0000000..b03a2de --- /dev/null +++ b/apps/web/src/app/polizas/page.tsx @@ -0,0 +1,478 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { AppShell } from "@/components/AppShell"; +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 (A–Z)" }, + { key: "number", label: "Número de póliza" }, + { key: "premium_desc", label: "Prima (mayor a menor)" }, +]; + +export default function PolizasPage() { + return ( + + + + ); +} + +function PolizasBrowser() { + const [stats, setStats] = useState(null); + const [facets, setFacets] = useState(null); + + const [query, setQuery] = useState(""); + const [status, setStatus] = useState("all"); + const [typeId, setTypeId] = useState(""); + const [providerId, setProviderId] = useState(""); + const [sort, setSort] = useState("expiry_desc"); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const debounceRef = useRef>(); + + 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 ( + <> +
+

Cartera de seguros

+

Pólizas

+ setStatus(s)} + /> + +
+ +
+
+ + ⌕ + + setQuery(e.target.value)} + placeholder="Buscar por póliza, cliente, placa, agente…" + aria-label="Buscar pólizas" + /> +
+
+ {STATUS_FILTERS.map((f) => ( + + ))} +
+
+ +
+ + + + + + + {filtered && ( + + )} +
+ + {data && !loading && !error && ( +
+ {data.total === 0 + ? "Sin resultados" + : `${formatNumber(data.total)} ${ + data.total === 1 ? "póliza" : "pólizas" + }`} + {query ? ` para “${query}”` : ""} +
+ )} + + {error ? ( +
+ {error} +
+ ) : loading ? ( + + ) : data && data.items.length === 0 ? ( + + ) : ( + <> +
+ {data?.items.map((p) => ( + + ))} +
+ {data && data.pageCount > 1 && ( + + )} + + )} + + ); +} + +/** 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 ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + 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 ( +
+ {cells.map((c) => ( + + ))} +
+
{formatNumber(stats.pending)}
+
Sin liquidar
+
+
+ ); +} + +/** 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 ( +
+ Prima neta vigente + {stats.premiumInForce.map((row) => ( + + {formatMoney(row.netPremium, row.currency)} + + {row.currency} · {formatNumber(row.count)}{" "} + {row.count === 1 ? "póliza" : "pólizas"} + + + ))} +
+ ); +} + +function PolicyRow({ p }: { p: PolicyListItem }) { + const premium = premiumHeadline(p); + const phrase = expiryPhrase(p.daysToExpiry); + const showPhrase = p.status === "expiring" || p.status === "active"; + + return ( + +
+
+ {p.policyNumber || "—"} + {p.policyType?.name && ( + + {p.policyType.name} + + )} + + {policyStatusLabel(p.status)} + + {!p.liquidated && ( + Sin liquidar + )} +
+
+ + {p.customerName} + + {p.insuranceProvider?.name && ( + <> + · + {p.insuranceProvider.name} + + )} + {p.vehicleCount > 0 && ( + <> + · + + {p.vehicleCount}{" "} + {p.vehicleCount === 1 ? "vehículo" : "vehículos"} + + + )} +
+
+
+
+ {formatMoney(premium.value, p.currency)} +
+
+ {formatDate(p.policyFrom)} – {formatDate(p.policyTo)} +
+ {showPhrase && phrase && ( +
{phrase}
+ )} +
+ + ); +} + +function Pager({ + page, + pageCount, + onChange, +}: { + page: number; + pageCount: number; + onChange: (p: number) => void; +}) { + return ( + + ); +} + +function ListSkeleton() { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ ); +} + +function EmptyState({ query }: { query: string }) { + return ( +
+
+ ⌕ +
+

Sin resultados

+

+ {query + ? `No encontramos pólizas para “${query}”.` + : "No hay pólizas que coincidan con los filtros."} +

+
+ ); +} diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index c536f2a..aa12bed 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState, type ReactNode } from "react"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import Link from "next/link"; import { logout, me } from "@/lib/api"; import type { AuthUser } from "@/lib/types"; @@ -9,10 +9,16 @@ import type { AuthUser } from "@/lib/types"; /** * Authenticated shell: gates on /auth/me, redirects to /login when the * session is missing, renders the brand header + logout, and wraps page - * content. Used by /clientes and /clientes/[id]. + * content. Used by every authenticated page. */ +const NAV = [ + { href: "/clientes", label: "Clientes" }, + { href: "/polizas", label: "Pólizas" }, +]; + export function AppShell({ children }: { children: ReactNode }) { const router = useRouter(); + const pathname = usePathname(); const [user, setUser] = useState(null); const [checking, setChecking] = useState(true); const [loggingOut, setLoggingOut] = useState(false); @@ -73,9 +79,19 @@ export function AppShell({ children }: { children: ReactNode }) {
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index f339538..ca70f67 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -7,6 +7,12 @@ import type { CustomerDetail, CustomerListResponse, CustomerStats, + PolicyDetail, + PolicyFacets, + PolicyListResponse, + PolicySort, + PolicyStats, + PolicyStatus, } from "./types"; export const API_ORIGIN = @@ -98,3 +104,53 @@ export function listCustomers( export function getCustomer(id: string): Promise { return apiFetch(`/customers/${id}`); } + +/* ------------------------------------------------------ Policies module */ + +/** Renewal horizon in days, shared by the list, stats and detail calls so the + * "por vencer" bucket means the same thing everywhere. */ +export const EXPIRY_WINDOW_DAYS = 30; + +export interface PolicyQuery { + query?: string; + page?: number; + pageSize?: number; + status?: PolicyStatus; + days?: number; + typeId?: string; + providerId?: string; + liquidated?: boolean; + sort?: PolicySort; +} + +export function listPolicies(q: PolicyQuery): Promise { + const params = new URLSearchParams(); + if (q.query) params.set("query", q.query); + if (q.page) params.set("page", String(q.page)); + if (q.pageSize) params.set("pageSize", String(q.pageSize)); + if (q.status) params.set("status", q.status); + if (q.days) params.set("days", String(q.days)); + if (q.typeId) params.set("typeId", q.typeId); + if (q.providerId) params.set("providerId", q.providerId); + if (q.liquidated !== undefined) params.set("liquidated", String(q.liquidated)); + if (q.sort) params.set("sort", q.sort); + const qs = params.toString(); + return apiFetch(`/policies${qs ? `?${qs}` : ""}`); +} + +export function getPolicyStats( + days: number = EXPIRY_WINDOW_DAYS, +): Promise { + return apiFetch(`/policies/stats?days=${days}`); +} + +export function getPolicyFacets(): Promise { + return apiFetch("/policies/facets"); +} + +export function getPolicy( + id: string, + days: number = EXPIRY_WINDOW_DAYS, +): Promise { + return apiFetch(`/policies/${id}?days=${days}`); +} diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts index cd4c0a7..c8d7d9d 100644 --- a/apps/web/src/lib/labels.ts +++ b/apps/web/src/lib/labels.ts @@ -1,6 +1,6 @@ // Spanish label maps + formatting helpers. Single source of truth for i18n. -import type { ServiceKind, TransactionDomain } from "./types"; +import type { PolicyStatus, ServiceKind, TransactionDomain } from "./types"; /** * Placeholder the migration writes when a legacy record had no name and none @@ -49,6 +49,44 @@ export function serviceKindGlyph(kind: ServiceKind): string { return SERVICE_KIND_GLYPH[kind] ?? "•"; } +// ----- policies ----- + +export const POLICY_STATUS_LABELS: Record = { + active: "Vigente", + expiring: "Por vencer", + expired: "Vencida", + undated: "Sin vigencia", +}; + +export function policyStatusLabel(status: PolicyStatus): string { + return POLICY_STATUS_LABELS[status] ?? status; +} + +/** + * Headline premium for a policy: always `netPremium`. + * + * The legacy `total` column did not survive the migration as a usable figure — + * of 2378 policies only 2 carry a non-zero total (1585 are literally 0, 791 + * null), and one of those two is *lower* than its own net premium. `netPremium` + * is populated on 2377 of 2378. `total` is still shown verbatim in the policy + * detail's condiciones grid, where it reads as source data rather than as the + * amount the customer owes. + */ +export function premiumHeadline(p: { + netPremium?: string | null; +}): { value: string | null; label: string } { + return { value: p.netPremium ?? null, label: "Prima neta" }; +} + +/** "vence en 12 días" / "venció hace 3 días" — null when the policy is undated. */ +export function expiryPhrase(days: number | null): string | null { + if (days === null) return null; + if (days === 0) return "vence hoy"; + if (days > 0) return `vence en ${days} ${days === 1 ? "día" : "días"}`; + const past = Math.abs(days); + return `venció hace ${past} ${past === 1 ? "día" : "días"}`; +} + // ----- formatting ----- export function formatMoney( diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 473350b..a3acf2d 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -166,6 +166,157 @@ export interface Policy { documents: DocumentRef[]; } +/* ------------------------------------------------------ Policies module */ + +/** + * Vigencia bucket computed by the API from `policyTo`. "undated" is a real + * bucket: a large share of migrated policies carry no end date at all. + */ +export type PolicyStatus = "active" | "expiring" | "expired" | "undated"; + +export type PolicySort = + | "expiry_desc" + | "expiry_asc" + | "customer" + | "number" + | "premium_desc"; + +export interface PolicyListItem { + id: string; + policyNumber: string | null; + agentName: string | null; + policyFrom: string | null; + policyTo: string | null; + netPremium: string | null; + total: string | null; + currency: string | null; + liquidated: boolean; + customerId: string; + customerName: string; + customerCity: string | null; + policyType: { id: string; name: string } | null; + insuranceProvider: { id: string; name: string } | null; + status: PolicyStatus; + /** Days until `policyTo`; negative when already expired, null when undated. */ + daysToExpiry: number | null; + vehicleCount: number; + installmentCount: number; + documentCount: number; +} + +export interface PolicyListResponse { + items: PolicyListItem[]; + total: number; + page: number; + pageSize: number; + pageCount: number; +} + +export interface PremiumRow { + currency: string; + total: string | null; + netPremium: string | null; + count: number; +} + +export interface PolicyStats { + total: number; + active: number; + expiring: number; + expired: number; + undated: number; + liquidated: number; + pending: number; + days: number; + premiumInForce: PremiumRow[]; +} + +export interface Facet { + id: string; + name: string; + count: number; +} + +export interface PolicyFacets { + types: Facet[]; + providers: Facet[]; +} + +export interface Adjuster { + id: string; + name: string | null; + phone?: string | null; + email?: string | null; +} + +export interface Claim { + id: string; + claimType: string | null; + incidentDate: string | null; + reportedDate: string | null; + description: string | null; + claimedAmount: string | null; + settledAmount: string | null; + settlementDate: string | null; + status?: string | null; + adjuster: Adjuster | null; +} + +export interface PolicyCustomerRef { + id: string; + name: string; + nameSource: string | null; + city: string | null; + state: string | null; + phone: string | null; + mobile: string | null; + email: string | null; +} + +export interface PolicyDetail { + id: string; + policyNumber: string | null; + agentName: string | null; + policyDate: string | null; + policyFrom: string | null; + policyTo: string | null; + coveragePeriodDays: number | null; + netPremium: string | null; + policyFee: string | null; + brokerFee: string | null; + commission: string | null; + total: string | null; + currency: string | null; + observations: string | null; + notes: string | null; + /** Legacy coverage columns the target schema doesn't model, kept verbatim. */ + coveragesJson: Record | null; + endorsement: boolean; + liquidated: boolean; + liquidationNumber: string | null; + liquidationDate: string | null; + legacySourceDb: string | null; + legacySourceTable: string | null; + legacyId: string | null; + status: PolicyStatus; + daysToExpiry: number | null; + customer: PolicyCustomerRef; + policyType: NamedRef & { id: string } | null; + insuranceProvider: (NamedRef & { id: string }) | null; + installments: Installment[]; + vehicles: Vehicle[]; + insuredDrivers: InsuredDriver[]; + beneficiaries: Beneficiary[]; + claims: Claim[]; + documents: DocumentRef[]; + properties: { + id: string; + addressLine1: string | null; + addressLine2: string | null; + zone: string | null; + }[]; +} + export type TransactionDomain = "UTILITY" | "INSURANCE" | "TRUST" | string; export interface TransactionType {