Insurance module: policy browser (list/search/detail) + renewals view
Plan step 4. Adds the policies API and the Spanish-first /polizas pages on top of the customer records the customer module already exposes. API (apps/api/src/policies): - GET /policies — search over policy number, customer name, agent, vehicle license plate, insured-driver name and legacy id; filters for vigencia bucket, ramo, aseguradora and liquidation state; five sort orders. - GET /policies/stats — bucket counts plus premium in force split by currency (MXN and USD can't be summed). - GET /policies/facets — ramos/aseguradoras with counts for the dropdowns. - GET /policies/:id — full policy plus the owning customer. Vigencia is derived from policyTo as active/expiring/expired/undated. "undated" is a real bucket rather than an error case: 528 of the 2378 migrated policies carry no end date at all. Web: - /polizas — renewals-first browser; the stat cells double as vigencia filters, with a secondary row for ramo, aseguradora and sort order. - /polizas/[id] — vigencia hero, condiciones y primas, pagos, vehículos, asegurados/beneficiarios, siniestros, the verbatim legacy coverage columns, and documents. - Nav gains Clientes | Pólizas with a real active state, and the two modules cross-link in both directions. Also fixes a display bug on the customer detail page: it headlined policies.total, which is dead data — 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. That rendered "$0.00 Total" on 1585 policies. Premium headlines and the premium sort now use netPremium (2377/2378 populated); total is shown only where it is non-zero, as raw source data. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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
|
templates (correctly excluded). The 538MB/882MB source files are mostly Access bloat, not
|
||||||
documents. **Migration steps 1-4 COMPLETE.**
|
documents. **Migration steps 1-4 COMPLETE.**
|
||||||
- **NEXT:** the Customer module API/web (Spanish-first) — all relational data + documents are
|
- **Customer module (plan step 3) — DONE**: `apps/api/src/customers/` (list/search/detail/stats)
|
||||||
loaded, so build the unified customer list/search/detail view.
|
+ `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 <env>` runs customers ->
|
- Full pipeline reproducible in one command: `run_all.py --env <env>` runs customers ->
|
||||||
properties -> policies -> transactions -> bank in order (all idempotent); add `--stage`
|
properties -> policies -> transactions -> bank in order (all idempotent); add `--stage`
|
||||||
to re-extract from the Access files first. Verified end-to-end against dev.
|
to re-extract from the Access files first. Verified end-to-end against dev.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { PrismaModule } from "./prisma/prisma.module";
|
|||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { CustomersModule } from "./customers/customers.module";
|
import { CustomersModule } from "./customers/customers.module";
|
||||||
|
import { PoliciesModule } from "./policies/policies.module";
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -13,6 +14,7 @@ import { AppController } from "./app.controller";
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
|
PoliciesModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
domainLabel,
|
domainLabel,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
|
premiumHeadline,
|
||||||
serviceKindGlyph,
|
serviceKindGlyph,
|
||||||
serviceKindLabel,
|
serviceKindLabel,
|
||||||
SIN_NOMBRE,
|
SIN_NOMBRE,
|
||||||
@@ -409,14 +410,15 @@ function PolizasSection({ policies }: { policies: Policy[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PolicyCard({ p }: { p: Policy }) {
|
function PolicyCard({ p }: { p: Policy }) {
|
||||||
const headline = p.total ?? p.netPremium;
|
const { value: headline, label: headlineLabel } = premiumHeadline(p);
|
||||||
const headlineLabel = p.total ? "Total" : "Prima neta";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="policy-card">
|
<div className="policy-card">
|
||||||
<div className="policy-head">
|
<div className="policy-head">
|
||||||
<div>
|
<div>
|
||||||
<div className="policy-num">{p.policyNumber || "—"}</div>
|
<Link href={`/polizas/${p.id}`} className="policy-num policy-num-link">
|
||||||
|
{p.policyNumber || "—"} →
|
||||||
|
</Link>
|
||||||
<div className="policy-type-row">
|
<div className="policy-type-row">
|
||||||
{p.policyType?.name && (
|
{p.policyType?.name && (
|
||||||
<span className="badge badge-seguros">
|
<span className="badge badge-seguros">
|
||||||
@@ -454,11 +456,6 @@ function PolicyCard({ p }: { p: Policy }) {
|
|||||||
{formatMoney(headline, p.currency)}
|
{formatMoney(headline, p.currency)}
|
||||||
</div>
|
</div>
|
||||||
<div className="policy-total-label">{headlineLabel}</div>
|
<div className="policy-total-label">{headlineLabel}</div>
|
||||||
{p.total && p.netPremium && (
|
|
||||||
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>
|
|
||||||
Prima neta {formatMoney(p.netPremium, p.currency)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1444,3 +1444,243 @@ button {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-top: 10px;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<AppShell>
|
||||||
|
<Detail id={id} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ id }: { id: string }) {
|
||||||
|
const [data, setData] = useState<PolicyDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 <DetailSkeleton />;
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BackLink />
|
||||||
|
<div className="state-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rise">
|
||||||
|
<BackLink />
|
||||||
|
<Hero data={data} />
|
||||||
|
<ClienteSection data={data} />
|
||||||
|
<CondicionesSection data={data} />
|
||||||
|
{data.installments.length > 0 && <PagosSection data={data} />}
|
||||||
|
{data.vehicles.length > 0 && <VehiculosSection data={data} />}
|
||||||
|
{(data.insuredDrivers.length > 0 || data.beneficiaries.length > 0) && (
|
||||||
|
<PersonasSection data={data} />
|
||||||
|
)}
|
||||||
|
{data.claims.length > 0 && <SiniestrosSection data={data} />}
|
||||||
|
<CoberturasSection data={data} />
|
||||||
|
<DocumentosSection data={data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackLink() {
|
||||||
|
return (
|
||||||
|
<Link href="/polizas" className="back-link">
|
||||||
|
← Volver a Pólizas
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ 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 (
|
||||||
|
<div className="detail-hero">
|
||||||
|
<div className="hero-top">
|
||||||
|
<div>
|
||||||
|
<h1 className="hero-name mono">{data.policyNumber || "—"}</h1>
|
||||||
|
<div className="hero-provenance">
|
||||||
|
{[data.policyType?.name, data.insuranceProvider?.name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "Sin ramo ni aseguradora registrados"}
|
||||||
|
</div>
|
||||||
|
{provenance && (
|
||||||
|
<div className="hero-provenance">Origen: {provenance}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hero-badges">
|
||||||
|
<span className={`badge status-${data.status}`}>
|
||||||
|
{policyStatusLabel(data.status)}
|
||||||
|
{phrase && data.status !== "expired" ? ` · ${phrase}` : ""}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
data.liquidated ? "badge-positive" : "badge-negative"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{data.liquidated ? "Liquidada" : "Sin liquidar"}
|
||||||
|
</span>
|
||||||
|
{data.endorsement && (
|
||||||
|
<span className="badge badge-on-dark">Endoso</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-facts">
|
||||||
|
{facts.map((f) => (
|
||||||
|
<div key={f.label}>
|
||||||
|
<div className="hero-fact-label">{f.label}</div>
|
||||||
|
<div className="hero-fact-value">{f.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- Cliente */
|
||||||
|
function ClienteSection({ data }: { data: PolicyDetail }) {
|
||||||
|
const c = data.customer;
|
||||||
|
const location = [c.city?.replace(/,\s*$/, ""), c.state]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="datos" title="Cliente" />
|
||||||
|
<div className="card">
|
||||||
|
<Link href={`/clientes/${c.id}`} className="owner-link">
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`owner-name${
|
||||||
|
c.name === SIN_NOMBRE ? " cust-name-missing" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</div>
|
||||||
|
<div className="cust-sub">
|
||||||
|
{location && <span>{location}</span>}
|
||||||
|
{location && (c.phone || c.email) && (
|
||||||
|
<span className="sep">·</span>
|
||||||
|
)}
|
||||||
|
{(c.phone || c.mobile) && <span>{c.phone || c.mobile}</span>}
|
||||||
|
{c.email && (
|
||||||
|
<>
|
||||||
|
<span className="sep">·</span>
|
||||||
|
<span>{c.email}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="owner-cta">Ver expediente →</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{data.properties.length > 0 && (
|
||||||
|
<div className="linked-props">
|
||||||
|
<div className="kv-label">Propiedades cubiertas</div>
|
||||||
|
{data.properties.map((p) => (
|
||||||
|
<div key={p.id} className="linked-prop">
|
||||||
|
{[p.addressLine1, p.addressLine2].filter(Boolean).join(", ") ||
|
||||||
|
"Propiedad"}
|
||||||
|
{p.zone && <span className="muted"> · Zona {p.zone}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------- Condiciones */
|
||||||
|
function CondicionesSection({ data }: { data: PolicyDetail }) {
|
||||||
|
const cur = data.currency;
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="seguros" title="Condiciones y primas" />
|
||||||
|
<div className="card">
|
||||||
|
<div className="kv-grid">
|
||||||
|
<KV label="Fecha de emisión" value={formatDate(data.policyDate)} />
|
||||||
|
<KV
|
||||||
|
label="Periodo de cobertura"
|
||||||
|
value={
|
||||||
|
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
|
||||||
|
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
|
||||||
|
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
||||||
|
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
||||||
|
{/* 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 && (
|
||||||
|
<KV label="Total" value={formatMoney(data.total, cur)} />
|
||||||
|
)}
|
||||||
|
<KV
|
||||||
|
label="Liquidación"
|
||||||
|
value={
|
||||||
|
data.liquidated
|
||||||
|
? [
|
||||||
|
data.liquidationNumber
|
||||||
|
? `No. ${data.liquidationNumber}`
|
||||||
|
: null,
|
||||||
|
data.liquidationDate
|
||||||
|
? formatDate(data.liquidationDate)
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "Liquidada"
|
||||||
|
: "Pendiente"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{data.observations && (
|
||||||
|
<div className="kv-block">
|
||||||
|
<div className="kv-label">Observaciones</div>
|
||||||
|
<div className="kv-value">{data.observations}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.notes && (
|
||||||
|
<div className="kv-block">
|
||||||
|
<div className="kv-label">Notas</div>
|
||||||
|
<div className="kv-value">{data.notes}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- Pagos */
|
||||||
|
function PagosSection({ data }: { data: PolicyDetail }) {
|
||||||
|
const paid = data.installments.filter((i) => i.paidDate).length;
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead
|
||||||
|
rule="cuenta"
|
||||||
|
title="Pagos"
|
||||||
|
count={data.installments.length}
|
||||||
|
countSuffix={`· ${paid} pagados`}
|
||||||
|
/>
|
||||||
|
<div className="card">
|
||||||
|
<div className="subpanel" style={{ margin: 16 }}>
|
||||||
|
{data.installments.map((inst) => (
|
||||||
|
<InstallmentRow key={inst.id} inst={inst} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstallmentRow({ inst }: { inst: Installment }) {
|
||||||
|
const method = inst.isCash
|
||||||
|
? "Efectivo"
|
||||||
|
: inst.checkNumber
|
||||||
|
? `Ref. ${inst.checkNumber}`
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="pay-row">
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||||
|
<span className="pay-seq">{inst.sequence}</span>
|
||||||
|
<span>
|
||||||
|
{inst.paidDate ? formatDate(inst.paidDate) : "Sin pagar"}
|
||||||
|
{inst.dueDate && !inst.paidDate && (
|
||||||
|
<span className="muted" style={{ fontSize: 11 }}>
|
||||||
|
{" "}
|
||||||
|
· vence {formatDate(inst.dueDate)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{method && (
|
||||||
|
<span className="muted" style={{ fontSize: 11 }}>
|
||||||
|
{" "}
|
||||||
|
· {method}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mono" style={{ fontWeight: 600 }}>
|
||||||
|
{formatMoney(inst.amount, inst.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- Vehículos */
|
||||||
|
function VehiculosSection({ data }: { data: PolicyDetail }) {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead
|
||||||
|
rule="servicios"
|
||||||
|
title="Vehículos asegurados"
|
||||||
|
count={data.vehicles.length}
|
||||||
|
/>
|
||||||
|
<div className="card">
|
||||||
|
<div className="veh-grid">
|
||||||
|
{data.vehicles.map((v) => (
|
||||||
|
<div className="veh-card" key={v.id}>
|
||||||
|
<div className="veh-title">
|
||||||
|
{[v.make, v.model, v.modelYear].filter(Boolean).join(" ") ||
|
||||||
|
"Vehículo"}
|
||||||
|
</div>
|
||||||
|
<div className="veh-facts">
|
||||||
|
{v.bodyType && <span>{v.bodyType}</span>}
|
||||||
|
{v.licensePlate && (
|
||||||
|
<span>
|
||||||
|
Placa: <span className="mono">{v.licensePlate}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{v.vinNumber && (
|
||||||
|
<span>
|
||||||
|
Serie: <span className="mono">{v.vinNumber}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{v.engineNumber && (
|
||||||
|
<span>
|
||||||
|
Motor: <span className="mono">{v.engineNumber}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------- Asegurados y beneficiarios */
|
||||||
|
function PersonasSection({ data }: { data: PolicyDetail }) {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="datos" title="Asegurados y beneficiarios" />
|
||||||
|
<div className="card">
|
||||||
|
<div className="policy-body">
|
||||||
|
{data.insuredDrivers.length > 0 && (
|
||||||
|
<div className="subpanel">
|
||||||
|
<div className="subpanel-title">
|
||||||
|
<span>Asegurados</span>
|
||||||
|
<span>{data.insuredDrivers.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mini-list">
|
||||||
|
{data.insuredDrivers.map((d) => (
|
||||||
|
<div key={d.id}>
|
||||||
|
{d.fullName || "—"}
|
||||||
|
{d.licenseNumber && (
|
||||||
|
<div className="mini-sub mono">Lic. {d.licenseNumber}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.beneficiaries.length > 0 && (
|
||||||
|
<div className="subpanel">
|
||||||
|
<div className="subpanel-title">
|
||||||
|
<span>Beneficiarios</span>
|
||||||
|
<span>{data.beneficiaries.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mini-list">
|
||||||
|
{data.beneficiaries.map((b) => (
|
||||||
|
<div key={b.id}>
|
||||||
|
{b.name || "—"}
|
||||||
|
{(b.phone || b.email) && (
|
||||||
|
<div className="mini-sub">
|
||||||
|
{[b.phone, b.email].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------- Siniestros */
|
||||||
|
function SiniestrosSection({ data }: { data: PolicyDetail }) {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="cuenta" title="Siniestros" count={data.claims.length} />
|
||||||
|
<div className="card">
|
||||||
|
{data.claims.map((c) => (
|
||||||
|
<div className="prop-card" key={c.id}>
|
||||||
|
<div className="prop-addr">{c.claimType || "Siniestro"}</div>
|
||||||
|
<div className="prop-meta">
|
||||||
|
{c.incidentDate && (
|
||||||
|
<span>Ocurrido: {formatDate(c.incidentDate)}</span>
|
||||||
|
)}
|
||||||
|
{c.reportedDate && (
|
||||||
|
<span>Reportado: {formatDate(c.reportedDate)}</span>
|
||||||
|
)}
|
||||||
|
{c.adjuster?.name && <span>Ajustador: {c.adjuster.name}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="kv-grid" style={{ marginTop: 12 }}>
|
||||||
|
<KV
|
||||||
|
label="Monto reclamado"
|
||||||
|
value={formatMoney(c.claimedAmount, data.currency)}
|
||||||
|
/>
|
||||||
|
<KV
|
||||||
|
label="Monto liquidado"
|
||||||
|
value={formatMoney(c.settledAmount, data.currency)}
|
||||||
|
/>
|
||||||
|
<KV label="Fecha de finiquito" value={formatDate(c.settlementDate)} />
|
||||||
|
{c.description && (
|
||||||
|
<div className="kv-block">
|
||||||
|
<div className="kv-label">Descripción</div>
|
||||||
|
<div className="kv-value">{c.description}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------- 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 (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="seguros" title="Coberturas" count={entries.length} />
|
||||||
|
<div className="card">
|
||||||
|
<div className="kv-grid">
|
||||||
|
{entries.map(([k, v]) => (
|
||||||
|
<div key={k}>
|
||||||
|
<div className="kv-label">{k}</div>
|
||||||
|
<div className="kv-value">{String(v)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||||
|
Campos de cobertura conservados tal cual desde el sistema anterior.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------- Documentos */
|
||||||
|
function DocumentosSection({ data }: { data: PolicyDetail }) {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="docs" title="Documentos" count={data.documents.length} />
|
||||||
|
<div className="card">
|
||||||
|
{data.documents.length === 0 ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
No hay documentos registrados para esta póliza.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="doc-list">
|
||||||
|
{data.documents.map((d, i) => (
|
||||||
|
<div className="doc-item" key={d.id ?? i}>
|
||||||
|
<span className="doc-icon" aria-hidden>
|
||||||
|
▤
|
||||||
|
</span>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="doc-type">{d.documentType || "Documento"}</div>
|
||||||
|
<div className="doc-key">{d.storageKey || "—"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||||
|
Los archivos se almacenan en el object storage (storageKey); no se
|
||||||
|
descargan desde esta vista.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ helpers */
|
||||||
|
function KV({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | null | undefined;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="kv-label">{label}</div>
|
||||||
|
<div className="kv-value">{value || "—"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHead({
|
||||||
|
rule,
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
countSuffix,
|
||||||
|
}: {
|
||||||
|
rule: string;
|
||||||
|
title: string;
|
||||||
|
count?: number;
|
||||||
|
countSuffix?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="section-head">
|
||||||
|
<span className={`section-rule ${rule}`} aria-hidden />
|
||||||
|
<h2 className="section-title">{title}</h2>
|
||||||
|
{count != null && (
|
||||||
|
<span className="section-count">
|
||||||
|
{count} {countSuffix ?? ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 16, width: 140, marginBottom: 18 }}
|
||||||
|
/>
|
||||||
|
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 200, borderRadius: 16, marginTop: 34 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 260, borderRadius: 16, marginTop: 34 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AppShell>
|
||||||
|
<PolizasBrowser />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolizasBrowser() {
|
||||||
|
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>
|
||||||
|
<h1 className="page-title">Pólizas</h1>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { logout, me } from "@/lib/api";
|
import { logout, me } from "@/lib/api";
|
||||||
import type { AuthUser } from "@/lib/types";
|
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
|
* Authenticated shell: gates on /auth/me, redirects to /login when the
|
||||||
* session is missing, renders the brand header + logout, and wraps page
|
* 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 }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [checking, setChecking] = useState(true);
|
const [checking, setChecking] = useState(true);
|
||||||
const [loggingOut, setLoggingOut] = useState(false);
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
@@ -73,9 +79,19 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="appbar-nav" aria-label="Principal">
|
<nav className="appbar-nav" aria-label="Principal">
|
||||||
<Link href="/clientes" className="appbar-link active">
|
{NAV.map((item) => {
|
||||||
Clientes
|
const active = pathname?.startsWith(item.href) ?? false;
|
||||||
</Link>
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`appbar-link${active ? " active" : ""}`}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
<span className="appbar-spacer" />
|
<span className="appbar-spacer" />
|
||||||
<div className="appbar-user">
|
<div className="appbar-user">
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import type {
|
|||||||
CustomerDetail,
|
CustomerDetail,
|
||||||
CustomerListResponse,
|
CustomerListResponse,
|
||||||
CustomerStats,
|
CustomerStats,
|
||||||
|
PolicyDetail,
|
||||||
|
PolicyFacets,
|
||||||
|
PolicyListResponse,
|
||||||
|
PolicySort,
|
||||||
|
PolicyStats,
|
||||||
|
PolicyStatus,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const API_ORIGIN =
|
export const API_ORIGIN =
|
||||||
@@ -98,3 +104,53 @@ export function listCustomers(
|
|||||||
export function getCustomer(id: string): Promise<CustomerDetail> {
|
export function getCustomer(id: string): Promise<CustomerDetail> {
|
||||||
return apiFetch<CustomerDetail>(`/customers/${id}`);
|
return apiFetch<CustomerDetail>(`/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<PolicyListResponse> {
|
||||||
|
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<PolicyListResponse>(`/policies${qs ? `?${qs}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPolicyStats(
|
||||||
|
days: number = EXPIRY_WINDOW_DAYS,
|
||||||
|
): Promise<PolicyStats> {
|
||||||
|
return apiFetch<PolicyStats>(`/policies/stats?days=${days}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPolicyFacets(): Promise<PolicyFacets> {
|
||||||
|
return apiFetch<PolicyFacets>("/policies/facets");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPolicy(
|
||||||
|
id: string,
|
||||||
|
days: number = EXPIRY_WINDOW_DAYS,
|
||||||
|
): Promise<PolicyDetail> {
|
||||||
|
return apiFetch<PolicyDetail>(`/policies/${id}?days=${days}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Spanish label maps + formatting helpers. Single source of truth for i18n.
|
// 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
|
* 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] ?? "•";
|
return SERVICE_KIND_GLYPH[kind] ?? "•";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- policies -----
|
||||||
|
|
||||||
|
export const POLICY_STATUS_LABELS: Record<PolicyStatus, string> = {
|
||||||
|
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 -----
|
// ----- formatting -----
|
||||||
|
|
||||||
export function formatMoney(
|
export function formatMoney(
|
||||||
|
|||||||
@@ -166,6 +166,157 @@ export interface Policy {
|
|||||||
documents: DocumentRef[];
|
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<string, unknown> | 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 type TransactionDomain = "UTILITY" | "INSURANCE" | "TRUST" | string;
|
||||||
|
|
||||||
export interface TransactionType {
|
export interface TransactionType {
|
||||||
|
|||||||
Reference in New Issue
Block a user