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:
@@ -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<CustomerDetail> {
|
||||
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.
|
||||
|
||||
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<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 -----
|
||||
|
||||
export function formatMoney(
|
||||
|
||||
@@ -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<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 interface TransactionType {
|
||||
|
||||
Reference in New Issue
Block a user