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:
2026-07-22 21:27:38 -07:00
co-authored by Claude Opus 4.8
parent fa9b696752
commit e2aba8bd17
13 changed files with 1957 additions and 16 deletions
+39 -1
View File
@@ -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(