From 7a46c30d9b469171ca4ccd32424f8a24d704a7d7 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Thu, 23 Jul 2026 12:22:02 -0700 Subject: [PATCH] feat(policies): full CRUD + child editors + insurance lookups (plan phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Policy header, all five child collections, and the insurance reference catalogs become create/edit/delete-able on the RBAC foundation. API: - Policy gains archivedAt (soft-delete); list/browser default to archivedAt=null with ?includeArchived opt-in. - PoliciesService: header create/update/archive/restore (customer FK validated for a clean 404); add/update/remove for installments, vehicles, drivers, beneficiaries, claims — each scoped to its policy so one policy's id can't touch another's rows; lookups CRUD for providers, policy types, adjusters. - PoliciesController write routes: header create/update need STAFF+ (policy:create/update), archive/restore need MANAGER+ (policy:delete), every child route needs policy:update. New LookupsController at /lookups (read open; mutate needs lookup:manage / MANAGER+). Mutations audited. - DTOs (policy header, children, lookups); dates coerced; shared coerce.ts. Web: - Generic ChildCollection editor (config-driven add/edit/remove table), reused by both the policy detail child editors and the catalogs screen. - PolicyForm (header) with type/provider selects and a debounced CustomerPicker; /polizas/nuevo (accepts ?customerId prefill) and /polizas/[id]/editar. Policy detail: gated action bar (Editar/Archivar) + "Administrar detalles" child editors for all five collections. - /catalogos admin screen (aseguradoras/tipos/ajustadores), nav-gated on lookup:manage. "Nueva póliza" buttons on the list and on the customer detail (prefilled). api.ts + types for all of the above. Verified against dev: policy create (dates coerced, archivedAt null), installment/vehicle add, VIEWER child-add 403, cross-policy child guard 404, lookups CRUD with VIEWER 403 / MANAGER 201, archive drops from the default list and includeArchived surfaces it. Both apps compile clean. Co-Authored-By: Claude Opus 4.8 --- apps/api/src/common/coerce.ts | 10 + apps/api/src/policies/children.dto.ts | 78 +++++ apps/api/src/policies/lookup.dto.ts | 26 ++ apps/api/src/policies/lookups.controller.ts | 86 ++++++ apps/api/src/policies/policies.controller.ts | 179 ++++++++++- apps/api/src/policies/policies.module.ts | 3 +- apps/api/src/policies/policies.service.ts | 255 ++++++++++++++- apps/api/src/policies/policy.dto.ts | 62 ++++ apps/web/src/app/catalogos/page.tsx | 102 ++++++ apps/web/src/app/clientes/[id]/page.tsx | 37 ++- apps/web/src/app/globals.css | 53 ++++ apps/web/src/app/polizas/[id]/editar/page.tsx | 54 ++++ apps/web/src/app/polizas/[id]/page.tsx | 174 ++++++++++- apps/web/src/app/polizas/nuevo/page.tsx | 41 +++ apps/web/src/app/polizas/page.tsx | 10 +- apps/web/src/components/AppShell.tsx | 1 + apps/web/src/components/ChildCollection.tsx | 244 +++++++++++++++ apps/web/src/components/CustomerPicker.tsx | 90 ++++++ apps/web/src/components/PolicyForm.tsx | 292 ++++++++++++++++++ apps/web/src/lib/api.ts | 79 +++++ apps/web/src/lib/types.ts | 113 ++++++- packages/database/prisma/schema.prisma | 3 + 22 files changed, 1973 insertions(+), 19 deletions(-) create mode 100644 apps/api/src/common/coerce.ts create mode 100644 apps/api/src/policies/children.dto.ts create mode 100644 apps/api/src/policies/lookup.dto.ts create mode 100644 apps/api/src/policies/lookups.controller.ts create mode 100644 apps/api/src/policies/policy.dto.ts create mode 100644 apps/web/src/app/catalogos/page.tsx create mode 100644 apps/web/src/app/polizas/[id]/editar/page.tsx create mode 100644 apps/web/src/app/polizas/nuevo/page.tsx create mode 100644 apps/web/src/components/ChildCollection.tsx create mode 100644 apps/web/src/components/CustomerPicker.tsx create mode 100644 apps/web/src/components/PolicyForm.tsx diff --git a/apps/api/src/common/coerce.ts b/apps/api/src/common/coerce.ts new file mode 100644 index 0000000..26afda9 --- /dev/null +++ b/apps/api/src/common/coerce.ts @@ -0,0 +1,10 @@ +// Small shared coercers for DTO fields that arrive as strings from JSON. +// Distinguishing "field absent" (undefined -> leave unchanged) from +// "field cleared" (null/"" -> set null) matters for PATCH semantics. + +export function toDate(v?: string | null): Date | null | undefined { + if (v === undefined) return undefined; + if (v === "" || v === null) return null; + const d = new Date(v); + return isNaN(d.getTime()) ? undefined : d; +} diff --git a/apps/api/src/policies/children.dto.ts b/apps/api/src/policies/children.dto.ts new file mode 100644 index 0000000..f9dd95b --- /dev/null +++ b/apps/api/src/policies/children.dto.ts @@ -0,0 +1,78 @@ +import { + IsBoolean, + IsEmail, + IsEnum, + IsInt, + IsNumber, + IsOptional, + IsString, +} from "class-validator"; +import { Currency } from "@jorgecuadros/database"; + +// Each child DTO covers create; updates reuse the same shape with all fields +// optional via the corresponding Update class. Route supplies the policyId. + +export class InstallmentDto { + @IsInt() sequence!: number; + @IsOptional() @IsNumber() amount?: number; + @IsOptional() @IsEnum(Currency) currency?: Currency; + @IsOptional() @IsString() dueDate?: string; + @IsOptional() @IsString() paidDate?: string; + @IsOptional() @IsString() checkNumber?: string; + @IsOptional() @IsBoolean() isCash?: boolean; +} +export class UpdateInstallmentDto { + @IsOptional() @IsInt() sequence?: number; + @IsOptional() @IsNumber() amount?: number; + @IsOptional() @IsEnum(Currency) currency?: Currency; + @IsOptional() @IsString() dueDate?: string; + @IsOptional() @IsString() paidDate?: string; + @IsOptional() @IsString() checkNumber?: string; + @IsOptional() @IsBoolean() isCash?: boolean; +} + +export class VehicleDto { + @IsOptional() @IsString() make?: string; + @IsOptional() @IsString() model?: string; + @IsOptional() @IsString() modelYear?: string; + @IsOptional() @IsString() bodyType?: string; + @IsOptional() @IsString() engineNumber?: string; + @IsOptional() @IsString() licensePlate?: string; + @IsOptional() @IsString() vinNumber?: string; + @IsOptional() @IsString() stateCode?: string; + @IsOptional() @IsString() notes?: string; +} +export class UpdateVehicleDto extends VehicleDto {} + +export class DriverDto { + @IsOptional() @IsString() fullName?: string; + @IsOptional() @IsString() birthDate?: string; + @IsOptional() @IsString() sex?: string; + @IsOptional() @IsString() occupation?: string; + @IsOptional() @IsString() licenseNumber?: string; + @IsOptional() @IsString() licenseState?: string; +} +export class UpdateDriverDto extends DriverDto {} + +export class BeneficiaryDto { + @IsOptional() @IsString() name?: string; + @IsOptional() @IsString() address?: string; + @IsOptional() @IsString() phone?: string; + @IsOptional() @IsEmail() email?: string; +} +export class UpdateBeneficiaryDto extends BeneficiaryDto {} + +export class ClaimDto { + @IsOptional() @IsString() claimType?: string; + @IsOptional() @IsString() incidentDate?: string; + @IsOptional() @IsString() reportedDate?: string; + @IsOptional() @IsString() description?: string; + @IsOptional() @IsString() adjusterId?: string; + @IsOptional() @IsNumber() claimedAmount?: number; + @IsOptional() @IsNumber() settledAmount?: number; + @IsOptional() @IsString() settlementDate?: string; + @IsOptional() @IsString() checkNumber?: string; + @IsOptional() @IsBoolean() resolved?: boolean; + @IsOptional() @IsString() resolutionNotes?: string; +} +export class UpdateClaimDto extends ClaimDto {} diff --git a/apps/api/src/policies/lookup.dto.ts b/apps/api/src/policies/lookup.dto.ts new file mode 100644 index 0000000..02e6c31 --- /dev/null +++ b/apps/api/src/policies/lookup.dto.ts @@ -0,0 +1,26 @@ +import { IsOptional, IsString, MinLength } from "class-validator"; + +export class ProviderDto { + @IsString() @MinLength(1) name!: string; +} +export class UpdateProviderDto { + @IsOptional() @IsString() @MinLength(1) name?: string; +} + +export class PolicyTypeDto { + @IsString() @MinLength(1) name!: string; + @IsOptional() @IsString() shortDescription?: string; +} +export class UpdatePolicyTypeDto { + @IsOptional() @IsString() @MinLength(1) name?: string; + @IsOptional() @IsString() shortDescription?: string; +} + +export class AdjusterDto { + @IsOptional() @IsString() company?: string; + @IsOptional() @IsString() city?: string; + @IsOptional() @IsString() name?: string; + @IsOptional() @IsString() phone?: string; + @IsOptional() @IsString() beeper?: string; +} +export class UpdateAdjusterDto extends AdjusterDto {} diff --git a/apps/api/src/policies/lookups.controller.ts b/apps/api/src/policies/lookups.controller.ts new file mode 100644 index 0000000..9ca29a9 --- /dev/null +++ b/apps/api/src/policies/lookups.controller.ts @@ -0,0 +1,86 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from "@nestjs/common"; +import { AuthenticatedGuard } from "../auth/authenticated.guard"; +import { AbilityGuard } from "../auth/ability.guard"; +import { RequireAbility } from "../auth/require-ability.decorator"; +import { PoliciesService } from "./policies.service"; +import { + AdjusterDto, + PolicyTypeDto, + ProviderDto, + UpdateAdjusterDto, + UpdatePolicyTypeDto, + UpdateProviderDto, +} from "./lookup.dto"; + +/** + * Insurance reference data: providers, policy types, adjusters. Reading is open + * to any authenticated user (the policy form needs the options); mutating needs + * "lookup:manage" (MANAGER+). + */ +@UseGuards(AuthenticatedGuard, AbilityGuard) +@Controller("lookups") +export class LookupsController { + constructor(private readonly policies: PoliciesService) {} + + @Get() + list() { + return this.policies.listLookups(); + } + + @Post("providers") + @RequireAbility("lookup:manage") + createProvider(@Body() dto: ProviderDto) { + return this.policies.createProvider(dto); + } + @Patch("providers/:id") + @RequireAbility("lookup:manage") + updateProvider(@Param("id") id: string, @Body() dto: UpdateProviderDto) { + return this.policies.updateProvider(id, dto); + } + @Delete("providers/:id") + @RequireAbility("lookup:manage") + removeProvider(@Param("id") id: string) { + return this.policies.removeProvider(id); + } + + @Post("policy-types") + @RequireAbility("lookup:manage") + createType(@Body() dto: PolicyTypeDto) { + return this.policies.createPolicyType(dto); + } + @Patch("policy-types/:id") + @RequireAbility("lookup:manage") + updateType(@Param("id") id: string, @Body() dto: UpdatePolicyTypeDto) { + return this.policies.updatePolicyType(id, dto); + } + @Delete("policy-types/:id") + @RequireAbility("lookup:manage") + removeType(@Param("id") id: string) { + return this.policies.removePolicyType(id); + } + + @Post("adjusters") + @RequireAbility("lookup:manage") + createAdjuster(@Body() dto: AdjusterDto) { + return this.policies.createAdjuster(dto); + } + @Patch("adjusters/:id") + @RequireAbility("lookup:manage") + updateAdjuster(@Param("id") id: string, @Body() dto: UpdateAdjusterDto) { + return this.policies.updateAdjuster(id, dto); + } + @Delete("adjusters/:id") + @RequireAbility("lookup:manage") + removeAdjuster(@Param("id") id: string) { + return this.policies.removeAdjuster(id); + } +} diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 31a3d38..706af73 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -1,10 +1,37 @@ -import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common"; +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from "@nestjs/common"; +import { Request } from "express"; import { AuthenticatedGuard } from "../auth/authenticated.guard"; +import { AbilityGuard } from "../auth/ability.guard"; +import { RequireAbility } from "../auth/require-ability.decorator"; +import { AuditService } from "../common/audit.service"; import { PoliciesService, type PolicySort, type PolicyStatus, } from "./policies.service"; +import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; +import { + BeneficiaryDto, + ClaimDto, + DriverDto, + InstallmentDto, + UpdateBeneficiaryDto, + UpdateClaimDto, + UpdateDriverDto, + UpdateInstallmentDto, + VehicleDto, +} from "./children.dto"; const STATUSES: PolicyStatus[] = ["active", "expiring", "expired", "undated"]; const SORTS: PolicySort[] = [ @@ -15,15 +42,21 @@ const SORTS: PolicySort[] = [ "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) +@UseGuards(AuthenticatedGuard, AbilityGuard) @Controller("policies") export class PoliciesController { - constructor(private readonly policies: PoliciesService) {} + constructor( + private readonly policies: PoliciesService, + private readonly audit: AuditService, + ) {} + + private actingId(req: Request): string { + return (req.user as { id: string }).id; + } @Get("stats") stats(@Query("days") days?: string) { @@ -45,6 +78,7 @@ export class PoliciesController { @Query("typeId") typeId?: string, @Query("providerId") providerId?: string, @Query("liquidated") liquidated?: string, + @Query("includeArchived") includeArchived?: string, @Query("sort") sort?: string, ) { return this.policies.list({ @@ -59,6 +93,7 @@ export class PoliciesController { providerId: providerId || undefined, liquidated: liquidated === "true" ? true : liquidated === "false" ? false : undefined, + includeArchived: includeArchived === "true", sort: SORTS.includes(sort as PolicySort) ? (sort as PolicySort) : "expiry_desc", @@ -69,4 +104,140 @@ export class PoliciesController { detail(@Param("id") id: string, @Query("days") days?: string) { return this.policies.detail(id, parseDays(days)); } + + // --- header writes -------------------------------------------------------- + + @Post() + @RequireAbility("policy:create") + async create(@Body() dto: CreatePolicyDto, @Req() req: Request) { + const p = await this.policies.create(dto); + void this.audit.log(this.actingId(req), "policy.create", { policyId: p.id }); + return p; + } + + @Patch(":id") + @RequireAbility("policy:update") + async update(@Param("id") id: string, @Body() dto: UpdatePolicyDto, @Req() req: Request) { + const p = await this.policies.update(id, dto); + void this.audit.log(this.actingId(req), "policy.update", { policyId: id }); + return p; + } + + @Delete(":id") + @RequireAbility("policy:delete") + async archive(@Param("id") id: string, @Req() req: Request) { + const p = await this.policies.archive(id); + void this.audit.log(this.actingId(req), "policy.archive", { policyId: id }); + return p; + } + + @Post(":id/restore") + @RequireAbility("policy:delete") + async restore(@Param("id") id: string, @Req() req: Request) { + const p = await this.policies.restore(id); + void this.audit.log(this.actingId(req), "policy.restore", { policyId: id }); + return p; + } + + // --- children (all editing a policy => policy:update) --------------------- + + @Post(":id/installments") + @RequireAbility("policy:update") + addInstallment(@Param("id") id: string, @Body() dto: InstallmentDto) { + return this.policies.addInstallment(id, dto); + } + @Patch(":id/installments/:childId") + @RequireAbility("policy:update") + updateInstallment( + @Param("id") id: string, + @Param("childId") childId: string, + @Body() dto: UpdateInstallmentDto, + ) { + return this.policies.updateInstallment(id, childId, dto); + } + @Delete(":id/installments/:childId") + @RequireAbility("policy:update") + removeInstallment(@Param("id") id: string, @Param("childId") childId: string) { + return this.policies.removeInstallment(id, childId); + } + + @Post(":id/vehicles") + @RequireAbility("policy:update") + addVehicle(@Param("id") id: string, @Body() dto: VehicleDto) { + return this.policies.addVehicle(id, dto); + } + @Patch(":id/vehicles/:childId") + @RequireAbility("policy:update") + updateVehicle( + @Param("id") id: string, + @Param("childId") childId: string, + @Body() dto: VehicleDto, + ) { + return this.policies.updateVehicle(id, childId, dto); + } + @Delete(":id/vehicles/:childId") + @RequireAbility("policy:update") + removeVehicle(@Param("id") id: string, @Param("childId") childId: string) { + return this.policies.removeVehicle(id, childId); + } + + @Post(":id/drivers") + @RequireAbility("policy:update") + addDriver(@Param("id") id: string, @Body() dto: DriverDto) { + return this.policies.addDriver(id, dto); + } + @Patch(":id/drivers/:childId") + @RequireAbility("policy:update") + updateDriver( + @Param("id") id: string, + @Param("childId") childId: string, + @Body() dto: UpdateDriverDto, + ) { + return this.policies.updateDriver(id, childId, dto); + } + @Delete(":id/drivers/:childId") + @RequireAbility("policy:update") + removeDriver(@Param("id") id: string, @Param("childId") childId: string) { + return this.policies.removeDriver(id, childId); + } + + @Post(":id/beneficiaries") + @RequireAbility("policy:update") + addBeneficiary(@Param("id") id: string, @Body() dto: BeneficiaryDto) { + return this.policies.addBeneficiary(id, dto); + } + @Patch(":id/beneficiaries/:childId") + @RequireAbility("policy:update") + updateBeneficiary( + @Param("id") id: string, + @Param("childId") childId: string, + @Body() dto: UpdateBeneficiaryDto, + ) { + return this.policies.updateBeneficiary(id, childId, dto); + } + @Delete(":id/beneficiaries/:childId") + @RequireAbility("policy:update") + removeBeneficiary(@Param("id") id: string, @Param("childId") childId: string) { + return this.policies.removeBeneficiary(id, childId); + } + + @Post(":id/claims") + @RequireAbility("policy:update") + addClaim(@Param("id") id: string, @Body() dto: ClaimDto) { + return this.policies.addClaim(id, dto); + } + @Patch(":id/claims/:childId") + @RequireAbility("policy:update") + updateClaim( + @Param("id") id: string, + @Param("childId") childId: string, + @Body() dto: UpdateClaimDto, + ) { + return this.policies.updateClaim(id, childId, dto); + } + @Delete(":id/claims/:childId") + @RequireAbility("policy:update") + removeClaim(@Param("id") id: string, @Param("childId") childId: string) { + return this.policies.removeClaim(id, childId); + } } diff --git a/apps/api/src/policies/policies.module.ts b/apps/api/src/policies/policies.module.ts index f6ce42c..b3d1389 100644 --- a/apps/api/src/policies/policies.module.ts +++ b/apps/api/src/policies/policies.module.ts @@ -1,9 +1,10 @@ import { Module } from "@nestjs/common"; import { PoliciesController } from "./policies.controller"; +import { LookupsController } from "./lookups.controller"; import { PoliciesService } from "./policies.service"; @Module({ - controllers: [PoliciesController], + controllers: [PoliciesController, LookupsController], providers: [PoliciesService], }) export class PoliciesModule {} diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index a18e213..df64066 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -1,6 +1,27 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { Prisma } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; +import { toDate } from "../common/coerce"; +import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; +import { + BeneficiaryDto, + ClaimDto, + DriverDto, + InstallmentDto, + UpdateBeneficiaryDto, + UpdateClaimDto, + UpdateDriverDto, + UpdateInstallmentDto, + VehicleDto, +} from "./children.dto"; +import { + AdjusterDto, + PolicyTypeDto, + ProviderDto, + UpdateAdjusterDto, + UpdatePolicyTypeDto, + UpdateProviderDto, +} from "./lookup.dto"; /** * Vigencia buckets, derived from `policyTo` against today. `undated` is a real @@ -27,6 +48,7 @@ export interface ListParams { typeId?: string; providerId?: string; liquidated?: boolean; + includeArchived?: boolean; sort: PolicySort; } @@ -97,11 +119,13 @@ export class PoliciesService { /** 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 { query, page, pageSize, status, days, typeId, providerId, liquidated, + includeArchived, sort } = params; const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) }; + if (!includeArchived) where.archivedAt = null; + if (query && query.trim()) { const q = query.trim(); where.OR = [ @@ -134,6 +158,7 @@ export class PoliciesService { total: true, currency: true, liquidated: true, + archivedAt: true, customer: { select: { id: true, name: true, city: true } }, policyType: { select: { id: true, name: true } }, insuranceProvider: { select: { id: true, name: true } }, @@ -155,6 +180,7 @@ export class PoliciesService { total: r.total, currency: r.currency, liquidated: r.liquidated, + archived: r.archivedAt != null, customerId: r.customer.id, customerName: r.customer.name, customerCity: r.customer.city, @@ -278,4 +304,229 @@ export class PoliciesService { daysToExpiry: daysUntil(policy.policyTo, from), }; } + + // --- policy header writes ------------------------------------------------- + + private headerData(dto: CreatePolicyDto | UpdatePolicyDto) { + const { policyDate, policyFrom, policyTo, liquidationDate, ...rest } = + dto as CreatePolicyDto; + return { + ...rest, + ...(policyDate !== undefined && { policyDate: toDate(policyDate) }), + ...(policyFrom !== undefined && { policyFrom: toDate(policyFrom) }), + ...(policyTo !== undefined && { policyTo: toDate(policyTo) }), + ...(liquidationDate !== undefined && { liquidationDate: toDate(liquidationDate) }), + }; + } + + async create(dto: CreatePolicyDto) { + // Validate the customer FK up front for a clean 404 instead of a raw + // Prisma constraint error. + const customer = await this.prisma.customer.findUnique({ + where: { id: dto.customerId }, + select: { id: true }, + }); + if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`); + + return this.prisma.policy.create({ + data: { + ...this.headerData(dto), + policyNumber: dto.policyNumber, + customerId: dto.customerId, + }, + }); + } + + async update(id: string, dto: UpdatePolicyDto) { + await this.ensurePolicy(id); + return this.prisma.policy.update({ where: { id }, data: this.headerData(dto) }); + } + + async archive(id: string) { + await this.ensurePolicy(id); + return this.prisma.policy.update({ where: { id }, data: { archivedAt: new Date() } }); + } + + async restore(id: string) { + await this.ensurePolicy(id); + return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } }); + } + + private async ensurePolicy(id: string) { + const found = await this.prisma.policy.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!found) throw new NotFoundException(`Policy ${id} not found`); + } + + // --- child rows ----------------------------------------------------------- + // Each child is created under a policy and edited/removed by its own id, + // scoped to that policy so one policy's id can't touch another's rows. + + private async ensureChild( + model: "policyPaymentInstallment" | "vehicle" | "insuredDriver" | "policyBeneficiary" | "claim", + policyId: string, + childId: string, + ) { + await this.ensurePolicy(policyId); + // @ts-expect-error dynamic delegate access is safe for these known models + const row = await this.prisma[model].findFirst({ + where: { id: childId, policyId }, + select: { id: true }, + }); + if (!row) throw new NotFoundException(`Child ${childId} not found on policy ${policyId}`); + } + + async addInstallment(policyId: string, dto: InstallmentDto) { + await this.ensurePolicy(policyId); + return this.prisma.policyPaymentInstallment.create({ + data: { + policyId, + sequence: dto.sequence, + amount: dto.amount, + currency: dto.currency, + dueDate: toDate(dto.dueDate) ?? undefined, + paidDate: toDate(dto.paidDate) ?? undefined, + checkNumber: dto.checkNumber, + isCash: dto.isCash, + }, + }); + } + async updateInstallment(policyId: string, id: string, dto: UpdateInstallmentDto) { + await this.ensureChild("policyPaymentInstallment", policyId, id); + return this.prisma.policyPaymentInstallment.update({ + where: { id }, + data: { + sequence: dto.sequence, + amount: dto.amount, + currency: dto.currency, + ...(dto.dueDate !== undefined && { dueDate: toDate(dto.dueDate) }), + ...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }), + checkNumber: dto.checkNumber, + isCash: dto.isCash, + }, + }); + } + async removeInstallment(policyId: string, id: string) { + await this.ensureChild("policyPaymentInstallment", policyId, id); + return this.prisma.policyPaymentInstallment.delete({ where: { id } }); + } + + async addVehicle(policyId: string, dto: VehicleDto) { + await this.ensurePolicy(policyId); + return this.prisma.vehicle.create({ data: { policyId, ...dto } }); + } + async updateVehicle(policyId: string, id: string, dto: VehicleDto) { + await this.ensureChild("vehicle", policyId, id); + return this.prisma.vehicle.update({ where: { id }, data: { ...dto } }); + } + async removeVehicle(policyId: string, id: string) { + await this.ensureChild("vehicle", policyId, id); + return this.prisma.vehicle.delete({ where: { id } }); + } + + async addDriver(policyId: string, dto: DriverDto) { + await this.ensurePolicy(policyId); + return this.prisma.insuredDriver.create({ + data: { policyId, ...dto, birthDate: toDate(dto.birthDate) ?? undefined }, + }); + } + async updateDriver(policyId: string, id: string, dto: UpdateDriverDto) { + await this.ensureChild("insuredDriver", policyId, id); + return this.prisma.insuredDriver.update({ + where: { id }, + data: { ...dto, ...(dto.birthDate !== undefined && { birthDate: toDate(dto.birthDate) }) }, + }); + } + async removeDriver(policyId: string, id: string) { + await this.ensureChild("insuredDriver", policyId, id); + return this.prisma.insuredDriver.delete({ where: { id } }); + } + + async addBeneficiary(policyId: string, dto: BeneficiaryDto) { + await this.ensurePolicy(policyId); + return this.prisma.policyBeneficiary.create({ data: { policyId, ...dto } }); + } + async updateBeneficiary(policyId: string, id: string, dto: UpdateBeneficiaryDto) { + await this.ensureChild("policyBeneficiary", policyId, id); + return this.prisma.policyBeneficiary.update({ where: { id }, data: { ...dto } }); + } + async removeBeneficiary(policyId: string, id: string) { + await this.ensureChild("policyBeneficiary", policyId, id); + return this.prisma.policyBeneficiary.delete({ where: { id } }); + } + + async addClaim(policyId: string, dto: ClaimDto) { + await this.ensurePolicy(policyId); + return this.prisma.claim.create({ data: { policyId, ...this.claimData(dto) } }); + } + async updateClaim(policyId: string, id: string, dto: UpdateClaimDto) { + await this.ensureChild("claim", policyId, id); + return this.prisma.claim.update({ where: { id }, data: this.claimData(dto) }); + } + async removeClaim(policyId: string, id: string) { + await this.ensureChild("claim", policyId, id); + return this.prisma.claim.delete({ where: { id } }); + } + private claimData(dto: ClaimDto) { + const { incidentDate, reportedDate, settlementDate, ...rest } = dto; + return { + ...rest, + ...(incidentDate !== undefined && { incidentDate: toDate(incidentDate) }), + ...(reportedDate !== undefined && { reportedDate: toDate(reportedDate) }), + ...(settlementDate !== undefined && { settlementDate: toDate(settlementDate) }), + }; + } + + // --- lookups (providers / policy types / adjusters) ----------------------- + + listLookups() { + return this.prisma.$transaction([ + this.prisma.insuranceProvider.findMany({ + orderBy: { name: "asc" }, + select: { id: true, name: true, _count: { select: { policies: true } } }, + }), + this.prisma.policyType.findMany({ + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + shortDescription: true, + _count: { select: { policies: true } }, + }, + }), + this.prisma.adjuster.findMany({ orderBy: { name: "asc" } }), + ]).then(([providers, types, adjusters]) => ({ providers, types, adjusters })); + } + + createProvider(dto: ProviderDto) { + return this.prisma.insuranceProvider.create({ data: dto }); + } + updateProvider(id: string, dto: UpdateProviderDto) { + return this.prisma.insuranceProvider.update({ where: { id }, data: dto }); + } + removeProvider(id: string) { + return this.prisma.insuranceProvider.delete({ where: { id } }); + } + + createPolicyType(dto: PolicyTypeDto) { + return this.prisma.policyType.create({ data: dto }); + } + updatePolicyType(id: string, dto: UpdatePolicyTypeDto) { + return this.prisma.policyType.update({ where: { id }, data: dto }); + } + removePolicyType(id: string) { + return this.prisma.policyType.delete({ where: { id } }); + } + + createAdjuster(dto: AdjusterDto) { + return this.prisma.adjuster.create({ data: dto }); + } + updateAdjuster(id: string, dto: UpdateAdjusterDto) { + return this.prisma.adjuster.update({ where: { id }, data: dto }); + } + removeAdjuster(id: string) { + return this.prisma.adjuster.delete({ where: { id } }); + } } diff --git a/apps/api/src/policies/policy.dto.ts b/apps/api/src/policies/policy.dto.ts new file mode 100644 index 0000000..fe7cce1 --- /dev/null +++ b/apps/api/src/policies/policy.dto.ts @@ -0,0 +1,62 @@ +import { + IsBoolean, + IsInt, + IsNumber, + IsOptional, + IsString, + MinLength, +} from "class-validator"; +import { Currency } from "@jorgecuadros/database"; +import { IsEnum } from "class-validator"; + +/** Editable policy-header fields. coveragesJson (freeform legacy blob) is not + * exposed for editing. Dates arrive as ISO strings and are coerced by the + * service. `total` is legacy-dead data — the UI uses netPremium. */ +export class CreatePolicyDto { + @IsString() @MinLength(1) policyNumber!: string; + @IsString() @MinLength(1) customerId!: string; + + @IsOptional() @IsString() policyTypeId?: string; + @IsOptional() @IsString() insuranceProviderId?: string; + @IsOptional() @IsString() agentName?: string; + @IsOptional() @IsString() policyDate?: string; + @IsOptional() @IsString() policyFrom?: string; + @IsOptional() @IsString() policyTo?: string; + @IsOptional() @IsInt() coveragePeriodDays?: number; + @IsOptional() @IsNumber() netPremium?: number; + @IsOptional() @IsNumber() policyFee?: number; + @IsOptional() @IsNumber() brokerFee?: number; + @IsOptional() @IsNumber() commission?: number; + @IsOptional() @IsNumber() total?: number; + @IsOptional() @IsEnum(Currency) currency?: Currency; + @IsOptional() @IsString() observations?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsBoolean() endorsement?: boolean; + @IsOptional() @IsBoolean() liquidated?: boolean; + @IsOptional() @IsString() liquidationNumber?: string; + @IsOptional() @IsString() liquidationDate?: string; +} + +/** All header fields optional (customerId is not re-assignable on update). */ +export class UpdatePolicyDto { + @IsOptional() @IsString() @MinLength(1) policyNumber?: string; + @IsOptional() @IsString() policyTypeId?: string; + @IsOptional() @IsString() insuranceProviderId?: string; + @IsOptional() @IsString() agentName?: string; + @IsOptional() @IsString() policyDate?: string; + @IsOptional() @IsString() policyFrom?: string; + @IsOptional() @IsString() policyTo?: string; + @IsOptional() @IsInt() coveragePeriodDays?: number; + @IsOptional() @IsNumber() netPremium?: number; + @IsOptional() @IsNumber() policyFee?: number; + @IsOptional() @IsNumber() brokerFee?: number; + @IsOptional() @IsNumber() commission?: number; + @IsOptional() @IsNumber() total?: number; + @IsOptional() @IsEnum(Currency) currency?: Currency; + @IsOptional() @IsString() observations?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsBoolean() endorsement?: boolean; + @IsOptional() @IsBoolean() liquidated?: boolean; + @IsOptional() @IsString() liquidationNumber?: string; + @IsOptional() @IsString() liquidationDate?: string; +} diff --git a/apps/web/src/app/catalogos/page.tsx b/apps/web/src/app/catalogos/page.tsx new file mode 100644 index 0000000..f00fada --- /dev/null +++ b/apps/web/src/app/catalogos/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AppShell } from "@/components/AppShell"; +import { ChildCollection, type ChildConfig } from "@/components/ChildCollection"; +import { useCan } from "@/lib/abilities"; +import { createLookup, getLookups, removeLookup, updateLookup } from "@/lib/api"; +import type { LookupsResponse } from "@/lib/types"; + +const PROVIDER: ChildConfig = { + apiKind: "providers", + title: "Aseguradoras", + fields: [{ key: "name", label: "Nombre" }], +}; +const TYPE: ChildConfig = { + apiKind: "policy-types", + title: "Tipos de póliza", + fields: [ + { key: "name", label: "Nombre" }, + { key: "shortDescription", label: "Descripción" }, + ], +}; +const ADJUSTER: ChildConfig = { + apiKind: "adjusters", + title: "Ajustadores", + fields: [ + { key: "company", label: "Empresa" }, + { key: "name", label: "Nombre" }, + { key: "city", label: "Ciudad" }, + { key: "phone", label: "Teléfono" }, + { key: "beeper", label: "Beeper" }, + ], +}; + +export default function CatalogosPage() { + return ( + + + + ); +} + +function Catalogos() { + const canEdit = useCan("lookup:manage"); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + function reload() { + getLookups().then(setData).catch((e) => setError(e?.message ?? "Error al cargar.")); + } + useEffect(reload, []); + + if (!canEdit) { + return ( + <> +

Catálogos

+
+ No tiene permisos para administrar catálogos. +
+ + ); + } + + const section = (config: ChildConfig, rows: Record[]) => ( + { + await createLookup(config.apiKind, p); + reload(); + }} + onSave={async (id, p) => { + await updateLookup(config.apiKind, id, p); + reload(); + }} + onRemove={async (id) => { + await removeLookup(config.apiKind, id); + reload(); + }} + /> + ); + + return ( + <> +
+

Datos de referencia de seguros

+

Catálogos

+
+ {error &&
{error}
} + {!data ? ( +
+ ) : ( + <> + {section(PROVIDER, data.providers as unknown as Record[])} + {section(TYPE, data.types as unknown as Record[])} + {section(ADJUSTER, data.adjusters as unknown as Record[])} + + )} + + ); +} diff --git a/apps/web/src/app/clientes/[id]/page.tsx b/apps/web/src/app/clientes/[id]/page.tsx index d431c33..d1830a2 100644 --- a/apps/web/src/app/clientes/[id]/page.tsx +++ b/apps/web/src/app/clientes/[id]/page.tsx @@ -98,7 +98,11 @@ function Detail({ id }: { id: string }) { - + - +
+ + {canCreate && ( + + + Nueva póliza + + )} +
{policies.length === 0 ? (
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index b3ca463..14f4caa 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -315,6 +315,59 @@ button { color: var(--muted, #6b7280); margin: 4px 0 14px; } +.child-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} +.child-editor { + border-top: 1px solid rgba(0, 0, 0, 0.08); + margin-top: 12px; + padding-top: 14px; +} +.picker { + position: relative; +} +.picker-selected { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 8px; +} +.picker-list { + position: absolute; + z-index: 20; + top: 100%; + left: 0; + right: 0; + margin: 4px 0 0; + padding: 4px; + list-style: none; + background: var(--card-bg, #fff); + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + max-height: 280px; + overflow-y: auto; +} +.picker-item { + display: block; + width: 100%; + text-align: left; + padding: 8px 10px; + border: 0; + background: transparent; + border-radius: 6px; + cursor: pointer; + font: inherit; +} +.picker-item:hover { + background: rgba(0, 0, 0, 0.05); +} /* ============================================================================ Buttons diff --git a/apps/web/src/app/polizas/[id]/editar/page.tsx b/apps/web/src/app/polizas/[id]/editar/page.tsx new file mode 100644 index 0000000..cf50252 --- /dev/null +++ b/apps/web/src/app/polizas/[id]/editar/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { AppShell } from "@/components/AppShell"; +import { PolicyForm } from "@/components/PolicyForm"; +import { useCan } from "@/lib/abilities"; +import { getPolicy } from "@/lib/api"; +import type { PolicyDetail } from "@/lib/types"; + +export default function EditarPolizaPage({ + params, +}: { + params: { id: string }; +}) { + return ( + + + + ); +} + +function EditarPoliza({ id }: { id: string }) { + const allowed = useCan("policy:update"); + const [policy, setPolicy] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!allowed) return; + getPolicy(id) + .then(setPolicy) + .catch((e) => setError(e?.message ?? "No se pudo cargar la póliza.")); + }, [id, allowed]); + + return ( + <> +
+ ← Póliza +

Editar póliza

+
+ {!allowed ? ( +
+ No tiene permisos para editar pólizas. +
+ ) : error ? ( +
{error}
+ ) : !policy ? ( +
+ ) : ( + + )} + + ); +} diff --git a/apps/web/src/app/polizas/[id]/page.tsx b/apps/web/src/app/polizas/[id]/page.tsx index e9a6ea4..7e5a1d9 100644 --- a/apps/web/src/app/polizas/[id]/page.tsx +++ b/apps/web/src/app/polizas/[id]/page.tsx @@ -3,7 +3,17 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { AppShell } from "@/components/AppShell"; -import { getPolicy } from "@/lib/api"; +import { + addPolicyChild, + archivePolicy, + getLookups, + getPolicy, + removePolicyChild, + restorePolicy, + updatePolicyChild, +} from "@/lib/api"; +import { useCan } from "@/lib/abilities"; +import { ChildCollection, type ChildConfig } from "@/components/ChildCollection"; import { expiryPhrase, formatDate, @@ -12,7 +22,7 @@ import { premiumHeadline, SIN_NOMBRE, } from "@/lib/labels"; -import type { Installment, PolicyDetail } from "@/lib/types"; +import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types"; export default function PolizaDetailPage({ params, @@ -73,9 +83,14 @@ function Detail({ id }: { id: string }) { if (!data) return null; + const reload = () => getPolicy(id).then(setData).catch(() => {}); + return (
- +
+ + +
@@ -87,10 +102,163 @@ function Detail({ id }: { id: string }) { {data.claims.length > 0 && } +
); } +/** Edit / archive controls for the policy header. */ +function PolicyActions({ + data, + onChange, +}: { + data: PolicyDetail; + onChange: () => void; +}) { + const canEdit = useCan("policy:update"); + const canDelete = useCan("policy:delete"); + const [busy, setBusy] = useState(false); + const archived = data.archivedAt != null; + + async function toggle() { + const verb = archived ? "restaurar" : "archivar"; + if (!window.confirm(`¿Seguro que desea ${verb} esta póliza?`)) return; + setBusy(true); + try { + if (archived) await restorePolicy(data.id); + else await archivePolicy(data.id); + onChange(); + } catch (e) { + window.alert((e as Error)?.message ?? "No se pudo completar la acción."); + } finally { + setBusy(false); + } + } + + if (!canEdit && !canDelete) return null; + return ( +
+ {archived && Archivada} + {canEdit && ( + + Editar + + )} + {canDelete && ( + + )} +
+ ); +} + +/** Editable child collections — only shown to users who can edit the policy. */ +function ChildrenEditor({ + data, + onChange, +}: { + data: PolicyDetail; + onChange: () => void; +}) { + const canEdit = useCan("policy:update"); + const [adjusters, setAdjusters] = useState([]); + + useEffect(() => { + if (canEdit) getLookups().then((l) => setAdjusters(l.adjusters)).catch(() => {}); + }, [canEdit]); + + if (!canEdit) return null; + + const INSTALLMENTS: ChildConfig = { + apiKind: "installments", + title: "Pagos", + fields: [ + { key: "sequence", label: "Sec.", type: "number" }, + { key: "amount", label: "Monto", type: "number" }, + { key: "currency", label: "Moneda", type: "select", + options: [{ value: "MXN", label: "MXN" }, { value: "USD", label: "USD" }] }, + { key: "dueDate", label: "Vence", type: "date" }, + { key: "paidDate", label: "Pagado", type: "date" }, + { key: "checkNumber", label: "Cheque" }, + { key: "isCash", label: "Efectivo", type: "checkbox" }, + ], + }; + const VEHICLES: ChildConfig = { + apiKind: "vehicles", + title: "Vehículos", + fields: [ + { key: "make", label: "Marca" }, + { key: "model", label: "Modelo" }, + { key: "modelYear", label: "Año" }, + { key: "licensePlate", label: "Placa" }, + { key: "vinNumber", label: "VIN" }, + { key: "stateCode", label: "Estado" }, + ], + }; + const DRIVERS: ChildConfig = { + apiKind: "drivers", + title: "Conductores", + fields: [ + { key: "fullName", label: "Nombre" }, + { key: "birthDate", label: "Nacimiento", type: "date" }, + { key: "sex", label: "Sexo" }, + { key: "occupation", label: "Ocupación" }, + { key: "licenseNumber", label: "Licencia" }, + { key: "licenseState", label: "Estado" }, + ], + }; + const BENEFICIARIES: ChildConfig = { + apiKind: "beneficiaries", + title: "Beneficiarios", + fields: [ + { key: "name", label: "Nombre" }, + { key: "phone", label: "Teléfono" }, + { key: "email", label: "Correo" }, + { key: "address", label: "Dirección" }, + ], + }; + const CLAIMS: ChildConfig = { + apiKind: "claims", + title: "Siniestros", + fields: [ + { key: "claimType", label: "Tipo" }, + { key: "incidentDate", label: "Fecha", type: "date" }, + { key: "description", label: "Descripción" }, + { key: "adjusterId", label: "Ajustador", type: "select", + options: adjusters.map((a) => ({ value: a.id, label: a.name ?? a.company ?? a.id })) }, + { key: "claimedAmount", label: "Reclamado", type: "number" }, + { key: "settledAmount", label: "Pagado", type: "number" }, + { key: "resolved", label: "Resuelto", type: "checkbox" }, + ], + }; + + const bind = (cfg: ChildConfig, rows: Record[]) => ( + { await addPolicyChild(data.id, cfg.apiKind, p); onChange(); }} + onSave={async (cid, p) => { await updatePolicyChild(data.id, cfg.apiKind, cid, p); onChange(); }} + onRemove={async (cid) => { await removePolicyChild(data.id, cfg.apiKind, cid); onChange(); }} + /> + ); + + return ( +
+
+ +

Administrar detalles

+
+ {bind(INSTALLMENTS, data.installments as unknown as Record[])} + {bind(VEHICLES, data.vehicles as unknown as Record[])} + {bind(DRIVERS, data.insuredDrivers as unknown as Record[])} + {bind(BENEFICIARIES, data.beneficiaries as unknown as Record[])} + {bind(CLAIMS, data.claims as unknown as Record[])} +
+ ); +} + function BackLink() { return ( diff --git a/apps/web/src/app/polizas/nuevo/page.tsx b/apps/web/src/app/polizas/nuevo/page.tsx new file mode 100644 index 0000000..b607d79 --- /dev/null +++ b/apps/web/src/app/polizas/nuevo/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { Suspense } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { AppShell } from "@/components/AppShell"; +import { PolicyForm } from "@/components/PolicyForm"; +import { useCan } from "@/lib/abilities"; + +export default function NuevaPolizaPage() { + return ( + + + + + + ); +} + +function NuevaPoliza() { + const allowed = useCan("policy:create"); + const params = useSearchParams(); + const customerId = params.get("customerId") ?? undefined; + const customerName = params.get("customerName") ?? undefined; + + return ( + <> +
+ ← Pólizas +

Nueva póliza

+
+ {allowed ? ( + + ) : ( +
+ No tiene permisos para crear pólizas. +
+ )} + + ); +} diff --git a/apps/web/src/app/polizas/page.tsx b/apps/web/src/app/polizas/page.tsx index b03a2de..e480f05 100644 --- a/apps/web/src/app/polizas/page.tsx +++ b/apps/web/src/app/polizas/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { AppShell } from "@/components/AppShell"; +import { useCan } from "@/lib/abilities"; import { EXPIRY_WINDOW_DAYS, getPolicyFacets, @@ -54,6 +55,7 @@ export default function PolizasPage() { } function PolizasBrowser() { + const canCreate = useCan("policy:create"); const [stats, setStats] = useState(null); const [facets, setFacets] = useState(null); @@ -122,7 +124,13 @@ function PolizasBrowser() { <>

Cartera de seguros

-

Pólizas

+
+

Pólizas

+ + {canCreate && ( + + Nueva póliza + )} +
; + +function toDateInput(v: unknown): string { + if (!v || typeof v !== "string") return ""; + const d = new Date(v); + return isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10); +} + +/** Build editable values for a field from an existing row (or blank). */ +function rowToValues(fields: FieldDef[], row?: Record): RowValues { + const v: RowValues = {}; + for (const f of fields) { + const raw = row?.[f.key]; + if (f.type === "checkbox") v[f.key] = !!raw; + else if (f.type === "date") v[f.key] = toDateInput(raw); + else v[f.key] = raw == null ? "" : String(raw); + } + return v; +} + +/** Coerce editable values into an API payload (numbers/blanks handled). */ +function valuesToPayload(fields: FieldDef[], v: RowValues): Record { + const out: Record = {}; + for (const f of fields) { + const val = v[f.key]; + if (f.type === "checkbox") out[f.key] = !!val; + else if (f.type === "number") { + const s = String(val).trim(); + out[f.key] = s === "" ? undefined : Number(s); + } else { + const s = String(val).trim(); + out[f.key] = s === "" ? undefined : s; + } + } + return out; +} + +/** + * Generic add/edit/remove editor for a policy's child collection. The parent + * owns the API calls (so it can reload the policy afterward); this component is + * pure UI over `rows` plus add/save/remove callbacks. + */ +export function ChildCollection({ + config, + rows, + canEdit, + onAdd, + onSave, + onRemove, +}: { + config: ChildConfig; + rows: Record[]; + canEdit: boolean; + onAdd: (payload: Record) => Promise; + onSave: (id: string, payload: Record) => Promise; + onRemove: (id: string) => Promise; +}) { + const [editingId, setEditingId] = useState(null); + const [adding, setAdding] = useState(false); + const [values, setValues] = useState({}); + const [busy, setBusy] = useState(false); + + function startAdd() { + setEditingId(null); + setAdding(true); + setValues(rowToValues(config.fields)); + } + function startEdit(row: Record) { + setAdding(false); + setEditingId(String(row.id)); + setValues(rowToValues(config.fields, row)); + } + function cancel() { + setAdding(false); + setEditingId(null); + } + + async function submit() { + setBusy(true); + try { + const payload = valuesToPayload(config.fields, values); + if (editingId) await onSave(editingId, payload); + else await onAdd(payload); + cancel(); + } catch (e) { + window.alert((e as Error)?.message ?? "No se pudo guardar."); + } finally { + setBusy(false); + } + } + + async function remove(id: string) { + if (!window.confirm("¿Eliminar este registro?")) return; + try { + await onRemove(id); + } catch (e) { + window.alert((e as Error)?.message ?? "No se pudo eliminar."); + } + } + + function editor() { + return ( +
+
+ {config.fields.map((f) => ( + + ))} +
+
+ + +
+
+ ); + } + + return ( +
+
+

+ {config.title} + {rows.length} +

+ {canEdit && !adding && editingId === null && ( + + )} +
+ + {rows.length === 0 && !adding ? ( +
Sin registros.
+ ) : ( +
+ + + + {config.fields.map((f) => ( + + ))} + {canEdit && } + + + + {rows.map((row) => ( + + {config.fields.map((f) => ( + + ))} + {canEdit && ( + + )} + + ))} + +
{f.label}Acciones
{cellText(f, row[f.key])} +
+ + +
+
+
+ )} + + {(adding || editingId !== null) && editor()} +
+ ); +} + +function cellText(f: FieldDef, raw: unknown): string { + if (f.type === "checkbox") return raw ? "Sí" : "No"; + if (f.type === "date") return toDateInput(raw) || "—"; + if (f.type === "select") { + const opt = f.options?.find((o) => o.value === String(raw)); + return opt ? opt.label : "—"; + } + return raw == null || raw === "" ? "—" : String(raw); +} diff --git a/apps/web/src/components/CustomerPicker.tsx b/apps/web/src/components/CustomerPicker.tsx new file mode 100644 index 0000000..b54e262 --- /dev/null +++ b/apps/web/src/components/CustomerPicker.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { listCustomers } from "@/lib/api"; +import type { CustomerListItem } from "@/lib/types"; + +/** + * Debounced customer search + select. Reports the chosen customer's id and name + * upward. Used when creating a policy that isn't started from a customer page. + */ +export function CustomerPicker({ + value, + valueName, + onPick, +}: { + value: string; + valueName?: string; + onPick: (id: string, name: string) => void; +}) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [open, setOpen] = useState(false); + const debounce = useRef>(); + + useEffect(() => { + if (debounce.current) clearTimeout(debounce.current); + if (query.trim().length < 2) { + setResults([]); + return; + } + debounce.current = setTimeout(() => { + listCustomers({ query, pageSize: 8 }) + .then((r) => { + setResults(r.items); + setOpen(true); + }) + .catch(() => setResults([])); + }, 260); + return () => { + if (debounce.current) clearTimeout(debounce.current); + }; + }, [query]); + + return ( +
+ {value ? ( +
+ {valueName ?? "Cliente seleccionado"} + +
+ ) : ( + <> + setQuery(e.target.value)} + onFocus={() => results.length && setOpen(true)} + /> + {open && results.length > 0 && ( +
    + {results.map((c) => ( +
  • + +
  • + ))} +
+ )} + + )} +
+ ); +} diff --git a/apps/web/src/components/PolicyForm.tsx b/apps/web/src/components/PolicyForm.tsx new file mode 100644 index 0000000..2880db8 --- /dev/null +++ b/apps/web/src/components/PolicyForm.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { CustomerPicker } from "@/components/CustomerPicker"; +import { createPolicy, getLookups, updatePolicy } from "@/lib/api"; +import type { + Currency, + LookupsResponse, + PolicyDetail, + PolicyInput, +} from "@/lib/types"; + +function toDateInput(v: string | null | undefined): string { + if (!v) return ""; + const d = new Date(v); + return isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10); +} +function numOrUndef(v: string): number | undefined { + const t = v.trim(); + if (t === "") return undefined; + const n = Number(t); + return isNaN(n) ? undefined : n; +} +function s(v: string): string | undefined { + const t = v.trim(); + return t === "" ? undefined : t; +} + +type V = { + policyNumber: string; + policyTypeId: string; + insuranceProviderId: string; + agentName: string; + policyDate: string; + policyFrom: string; + policyTo: string; + netPremium: string; + policyFee: string; + brokerFee: string; + commission: string; + currency: Currency; + liquidated: boolean; + liquidationNumber: string; + liquidationDate: string; + endorsement: boolean; + observations: string; + notes: string; +}; + +function initial(p?: PolicyDetail): V { + return { + policyNumber: p?.policyNumber ?? "", + policyTypeId: p?.policyType?.id ?? "", + insuranceProviderId: p?.insuranceProvider?.id ?? "", + agentName: p?.agentName ?? "", + policyDate: toDateInput(p?.policyDate), + policyFrom: toDateInput(p?.policyFrom), + policyTo: toDateInput(p?.policyTo), + netPremium: p?.netPremium != null ? String(p.netPremium) : "", + policyFee: p?.policyFee != null ? String(p.policyFee) : "", + brokerFee: p?.brokerFee != null ? String(p.brokerFee) : "", + commission: p?.commission != null ? String(p.commission) : "", + currency: (p?.currency as Currency) ?? "MXN", + liquidated: p?.liquidated ?? false, + liquidationNumber: p?.liquidationNumber ?? "", + liquidationDate: toDateInput(p?.liquidationDate), + endorsement: p?.endorsement ?? false, + observations: p?.observations ?? "", + notes: p?.notes ?? "", + }; +} + +export function PolicyForm({ + policy, + fixedCustomerId, + fixedCustomerName, +}: { + policy?: PolicyDetail; + fixedCustomerId?: string; + fixedCustomerName?: string; +}) { + const router = useRouter(); + const editing = !!policy; + const [v, setV] = useState(() => initial(policy)); + const [lookups, setLookups] = useState(null); + const [customerId, setCustomerId] = useState( + policy?.customer.id ?? fixedCustomerId ?? "", + ); + const [customerName, setCustomerName] = useState( + policy?.customer.name ?? fixedCustomerName ?? "", + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + getLookups().then(setLookups).catch(() => setLookups(null)); + }, []); + + function set(k: K, val: V[K]) { + setV((p) => ({ ...p, [k]: val })); + } + + async function submit(e: React.FormEvent) { + e.preventDefault(); + if (!customerId) { + setError("Seleccione un cliente."); + return; + } + setSaving(true); + setError(null); + const base = { + policyNumber: v.policyNumber.trim(), + policyTypeId: s(v.policyTypeId), + insuranceProviderId: s(v.insuranceProviderId), + agentName: s(v.agentName), + policyDate: s(v.policyDate), + policyFrom: s(v.policyFrom), + policyTo: s(v.policyTo), + netPremium: numOrUndef(v.netPremium), + policyFee: numOrUndef(v.policyFee), + brokerFee: numOrUndef(v.brokerFee), + commission: numOrUndef(v.commission), + currency: v.currency, + liquidated: v.liquidated, + liquidationNumber: s(v.liquidationNumber), + liquidationDate: s(v.liquidationDate), + endorsement: v.endorsement, + observations: s(v.observations), + notes: s(v.notes), + }; + try { + if (editing) { + const saved = await updatePolicy(policy!.id, base); + router.push(`/polizas/${saved.id}`); + } else { + const payload: PolicyInput = { ...base, customerId }; + const saved = await createPolicy(payload); + router.push(`/polizas/${saved.id}`); + } + } catch (e2) { + setError((e2 as Error)?.message ?? "No se pudo guardar la póliza."); + setSaving(false); + } + } + + return ( +
+ {error &&
{error}
} + +
+

Datos de la póliza

+
+ + + + + + +
+
+ +
+

Vigencia y prima

+
+ + + + + + +
+
+ +
+

Liquidación

+
+ + + + +
+