feat(policies): full CRUD + child editors + insurance lookups (plan phase 3)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { 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 {
|
import {
|
||||||
PoliciesService,
|
PoliciesService,
|
||||||
type PolicySort,
|
type PolicySort,
|
||||||
type PolicyStatus,
|
type PolicyStatus,
|
||||||
} from "./policies.service";
|
} 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 STATUSES: PolicyStatus[] = ["active", "expiring", "expired", "undated"];
|
||||||
const SORTS: PolicySort[] = [
|
const SORTS: PolicySort[] = [
|
||||||
@@ -15,15 +42,21 @@ const SORTS: PolicySort[] = [
|
|||||||
"premium_desc",
|
"premium_desc",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Clamped expiry window; 30 days is the default renewal horizon. */
|
|
||||||
function parseDays(days?: string): number {
|
function parseDays(days?: string): number {
|
||||||
return Math.min(365, Math.max(1, Number(days) || 30));
|
return Math.min(365, Math.max(1, Number(days) || 30));
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard)
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
@Controller("policies")
|
@Controller("policies")
|
||||||
export class PoliciesController {
|
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")
|
@Get("stats")
|
||||||
stats(@Query("days") days?: string) {
|
stats(@Query("days") days?: string) {
|
||||||
@@ -45,6 +78,7 @@ export class PoliciesController {
|
|||||||
@Query("typeId") typeId?: string,
|
@Query("typeId") typeId?: string,
|
||||||
@Query("providerId") providerId?: string,
|
@Query("providerId") providerId?: string,
|
||||||
@Query("liquidated") liquidated?: string,
|
@Query("liquidated") liquidated?: string,
|
||||||
|
@Query("includeArchived") includeArchived?: string,
|
||||||
@Query("sort") sort?: string,
|
@Query("sort") sort?: string,
|
||||||
) {
|
) {
|
||||||
return this.policies.list({
|
return this.policies.list({
|
||||||
@@ -59,6 +93,7 @@ export class PoliciesController {
|
|||||||
providerId: providerId || undefined,
|
providerId: providerId || undefined,
|
||||||
liquidated:
|
liquidated:
|
||||||
liquidated === "true" ? true : liquidated === "false" ? false : undefined,
|
liquidated === "true" ? true : liquidated === "false" ? false : undefined,
|
||||||
|
includeArchived: includeArchived === "true",
|
||||||
sort: SORTS.includes(sort as PolicySort)
|
sort: SORTS.includes(sort as PolicySort)
|
||||||
? (sort as PolicySort)
|
? (sort as PolicySort)
|
||||||
: "expiry_desc",
|
: "expiry_desc",
|
||||||
@@ -69,4 +104,140 @@ export class PoliciesController {
|
|||||||
detail(@Param("id") id: string, @Query("days") days?: string) {
|
detail(@Param("id") id: string, @Query("days") days?: string) {
|
||||||
return this.policies.detail(id, parseDays(days));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { PoliciesController } from "./policies.controller";
|
import { PoliciesController } from "./policies.controller";
|
||||||
|
import { LookupsController } from "./lookups.controller";
|
||||||
import { PoliciesService } from "./policies.service";
|
import { PoliciesService } from "./policies.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PoliciesController],
|
controllers: [PoliciesController, LookupsController],
|
||||||
providers: [PoliciesService],
|
providers: [PoliciesService],
|
||||||
})
|
})
|
||||||
export class PoliciesModule {}
|
export class PoliciesModule {}
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
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
|
* Vigencia buckets, derived from `policyTo` against today. `undated` is a real
|
||||||
@@ -27,6 +48,7 @@ export interface ListParams {
|
|||||||
typeId?: string;
|
typeId?: string;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
liquidated?: boolean;
|
liquidated?: boolean;
|
||||||
|
includeArchived?: boolean;
|
||||||
sort: PolicySort;
|
sort: PolicySort;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +119,13 @@ export class PoliciesService {
|
|||||||
|
|
||||||
/** Policy list with search, vigencia/type/provider filters, paginated. */
|
/** Policy list with search, vigencia/type/provider filters, paginated. */
|
||||||
async list(params: ListParams) {
|
async list(params: ListParams) {
|
||||||
const { query, page, pageSize, status, days, typeId, providerId, liquidated, sort } =
|
const { query, page, pageSize, status, days, typeId, providerId, liquidated,
|
||||||
params;
|
includeArchived, sort } = params;
|
||||||
|
|
||||||
const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) };
|
const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) };
|
||||||
|
|
||||||
|
if (!includeArchived) where.archivedAt = null;
|
||||||
|
|
||||||
if (query && query.trim()) {
|
if (query && query.trim()) {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
where.OR = [
|
where.OR = [
|
||||||
@@ -134,6 +158,7 @@ export class PoliciesService {
|
|||||||
total: true,
|
total: true,
|
||||||
currency: true,
|
currency: true,
|
||||||
liquidated: true,
|
liquidated: true,
|
||||||
|
archivedAt: true,
|
||||||
customer: { select: { id: true, name: true, city: true } },
|
customer: { select: { id: true, name: true, city: true } },
|
||||||
policyType: { select: { id: true, name: true } },
|
policyType: { select: { id: true, name: true } },
|
||||||
insuranceProvider: { select: { id: true, name: true } },
|
insuranceProvider: { select: { id: true, name: true } },
|
||||||
@@ -155,6 +180,7 @@ export class PoliciesService {
|
|||||||
total: r.total,
|
total: r.total,
|
||||||
currency: r.currency,
|
currency: r.currency,
|
||||||
liquidated: r.liquidated,
|
liquidated: r.liquidated,
|
||||||
|
archived: r.archivedAt != null,
|
||||||
customerId: r.customer.id,
|
customerId: r.customer.id,
|
||||||
customerName: r.customer.name,
|
customerName: r.customer.name,
|
||||||
customerCity: r.customer.city,
|
customerCity: r.customer.city,
|
||||||
@@ -278,4 +304,229 @@ export class PoliciesService {
|
|||||||
daysToExpiry: daysUntil(policy.policyTo, from),
|
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 } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<AppShell>
|
||||||
|
<Catalogos />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Catalogos() {
|
||||||
|
const canEdit = useCan("lookup:manage");
|
||||||
|
const [data, setData] = useState<LookupsResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
getLookups().then(setData).catch((e) => setError(e?.message ?? "Error al cargar."));
|
||||||
|
}
|
||||||
|
useEffect(reload, []);
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head"><h1 className="page-title">Catálogos</h1></div>
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para administrar catálogos.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const section = (config: ChildConfig, rows: Record<string, unknown>[]) => (
|
||||||
|
<ChildCollection
|
||||||
|
config={config}
|
||||||
|
rows={rows}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onAdd={async (p) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<p className="eyebrow">Datos de referencia de seguros</p>
|
||||||
|
<h1 className="page-title">Catálogos</h1>
|
||||||
|
</div>
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
{!data ? (
|
||||||
|
<div className="empty-inline"><span className="spinner" aria-label="Cargando" /></div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{section(PROVIDER, data.providers as unknown as Record<string, unknown>[])}
|
||||||
|
{section(TYPE, data.types as unknown as Record<string, unknown>[])}
|
||||||
|
{section(ADJUSTER, data.adjusters as unknown as Record<string, unknown>[])}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,7 +98,11 @@ function Detail({ id }: { id: string }) {
|
|||||||
|
|
||||||
<DatosSection data={data} />
|
<DatosSection data={data} />
|
||||||
<PropiedadesSection properties={data.properties} />
|
<PropiedadesSection properties={data.properties} />
|
||||||
<PolizasSection policies={data.policies} />
|
<PolizasSection
|
||||||
|
policies={data.policies}
|
||||||
|
customerId={data.id}
|
||||||
|
customerName={data.name}
|
||||||
|
/>
|
||||||
<EstadoCuentaSection
|
<EstadoCuentaSection
|
||||||
customerId={data.id}
|
customerId={data.id}
|
||||||
summary={data.transactionSummary}
|
summary={data.transactionSummary}
|
||||||
@@ -452,14 +456,33 @@ function PropertyCard({ p }: { p: Property }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------ Pólizas de seguro */
|
/* ------------------------------------------------------ Pólizas de seguro */
|
||||||
function PolizasSection({ policies }: { policies: Policy[] }) {
|
function PolizasSection({
|
||||||
|
policies,
|
||||||
|
customerId,
|
||||||
|
customerName,
|
||||||
|
}: {
|
||||||
|
policies: Policy[];
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
}) {
|
||||||
|
const canCreate = useCan("policy:create");
|
||||||
return (
|
return (
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<SectionHead
|
<div className="detail-actionbar">
|
||||||
rule="seguros"
|
<SectionHead
|
||||||
title="Pólizas de seguro"
|
rule="seguros"
|
||||||
count={policies.length}
|
title="Pólizas de seguro"
|
||||||
/>
|
count={policies.length}
|
||||||
|
/>
|
||||||
|
{canCreate && (
|
||||||
|
<Link
|
||||||
|
href={`/polizas/nuevo?customerId=${customerId}&customerName=${encodeURIComponent(customerName)}`}
|
||||||
|
className="btn btn-outline"
|
||||||
|
>
|
||||||
|
+ Nueva póliza
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{policies.length === 0 ? (
|
{policies.length === 0 ? (
|
||||||
<div className="empty-inline">
|
<div className="empty-inline">
|
||||||
|
|||||||
@@ -315,6 +315,59 @@ button {
|
|||||||
color: var(--muted, #6b7280);
|
color: var(--muted, #6b7280);
|
||||||
margin: 4px 0 14px;
|
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
|
Buttons
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<AppShell>
|
||||||
|
<EditarPoliza id={params.id} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditarPoliza({ id }: { id: string }) {
|
||||||
|
const allowed = useCan("policy:update");
|
||||||
|
const [policy, setPolicy] = useState<PolicyDetail | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowed) return;
|
||||||
|
getPolicy(id)
|
||||||
|
.then(setPolicy)
|
||||||
|
.catch((e) => setError(e?.message ?? "No se pudo cargar la póliza."));
|
||||||
|
}, [id, allowed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<Link href={`/polizas/${id}`} className="back-link">← Póliza</Link>
|
||||||
|
<h1 className="page-title">Editar póliza</h1>
|
||||||
|
</div>
|
||||||
|
{!allowed ? (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para editar pólizas.
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="state-box state-error">{error}</div>
|
||||||
|
) : !policy ? (
|
||||||
|
<div className="empty-inline"><span className="spinner" aria-label="Cargando" /></div>
|
||||||
|
) : (
|
||||||
|
<PolicyForm policy={policy} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,17 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/AppShell";
|
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 {
|
import {
|
||||||
expiryPhrase,
|
expiryPhrase,
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -12,7 +22,7 @@ import {
|
|||||||
premiumHeadline,
|
premiumHeadline,
|
||||||
SIN_NOMBRE,
|
SIN_NOMBRE,
|
||||||
} from "@/lib/labels";
|
} from "@/lib/labels";
|
||||||
import type { Installment, PolicyDetail } from "@/lib/types";
|
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
|
||||||
|
|
||||||
export default function PolizaDetailPage({
|
export default function PolizaDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -73,9 +83,14 @@ function Detail({ id }: { id: string }) {
|
|||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
const reload = () => getPolicy(id).then(setData).catch(() => {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rise">
|
<div className="rise">
|
||||||
<BackLink />
|
<div className="detail-actionbar">
|
||||||
|
<BackLink />
|
||||||
|
<PolicyActions data={data} onChange={reload} />
|
||||||
|
</div>
|
||||||
<Hero data={data} />
|
<Hero data={data} />
|
||||||
<ClienteSection data={data} />
|
<ClienteSection data={data} />
|
||||||
<CondicionesSection data={data} />
|
<CondicionesSection data={data} />
|
||||||
@@ -87,10 +102,163 @@ function Detail({ id }: { id: string }) {
|
|||||||
{data.claims.length > 0 && <SiniestrosSection data={data} />}
|
{data.claims.length > 0 && <SiniestrosSection data={data} />}
|
||||||
<CoberturasSection data={data} />
|
<CoberturasSection data={data} />
|
||||||
<DocumentosSection data={data} />
|
<DocumentosSection data={data} />
|
||||||
|
<ChildrenEditor data={data} onChange={reload} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="row-actions">
|
||||||
|
{archived && <span className="badge badge-negative">Archivada</span>}
|
||||||
|
{canEdit && (
|
||||||
|
<Link href={`/polizas/${data.id}/editar`} className="btn btn-outline">
|
||||||
|
Editar
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={toggle} disabled={busy}>
|
||||||
|
{archived ? "Restaurar" : "Archivar"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<AdjusterRow[]>([]);
|
||||||
|
|
||||||
|
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<string, unknown>[]) => (
|
||||||
|
<ChildCollection
|
||||||
|
config={cfg}
|
||||||
|
rows={rows}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onAdd={async (p) => { 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 (
|
||||||
|
<section className="section">
|
||||||
|
<div className="section-head">
|
||||||
|
<span className="section-rule cuenta" aria-hidden />
|
||||||
|
<h2 className="section-title">Administrar detalles</h2>
|
||||||
|
</div>
|
||||||
|
{bind(INSTALLMENTS, data.installments as unknown as Record<string, unknown>[])}
|
||||||
|
{bind(VEHICLES, data.vehicles as unknown as Record<string, unknown>[])}
|
||||||
|
{bind(DRIVERS, data.insuredDrivers as unknown as Record<string, unknown>[])}
|
||||||
|
{bind(BENEFICIARIES, data.beneficiaries as unknown as Record<string, unknown>[])}
|
||||||
|
{bind(CLAIMS, data.claims as unknown as Record<string, unknown>[])}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BackLink() {
|
function BackLink() {
|
||||||
return (
|
return (
|
||||||
<Link href="/polizas" className="back-link">
|
<Link href="/polizas" className="back-link">
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<AppShell>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<NuevaPoliza />
|
||||||
|
</Suspense>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NuevaPoliza() {
|
||||||
|
const allowed = useCan("policy:create");
|
||||||
|
const params = useSearchParams();
|
||||||
|
const customerId = params.get("customerId") ?? undefined;
|
||||||
|
const customerName = params.get("customerName") ?? undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<Link href="/polizas" className="back-link">← Pólizas</Link>
|
||||||
|
<h1 className="page-title">Nueva póliza</h1>
|
||||||
|
</div>
|
||||||
|
{allowed ? (
|
||||||
|
<PolicyForm fixedCustomerId={customerId} fixedCustomerName={customerName} />
|
||||||
|
) : (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para crear pólizas.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
import {
|
import {
|
||||||
EXPIRY_WINDOW_DAYS,
|
EXPIRY_WINDOW_DAYS,
|
||||||
getPolicyFacets,
|
getPolicyFacets,
|
||||||
@@ -54,6 +55,7 @@ export default function PolizasPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PolizasBrowser() {
|
function PolizasBrowser() {
|
||||||
|
const canCreate = useCan("policy:create");
|
||||||
const [stats, setStats] = useState<PolicyStats | null>(null);
|
const [stats, setStats] = useState<PolicyStats | null>(null);
|
||||||
const [facets, setFacets] = useState<PolicyFacets | null>(null);
|
const [facets, setFacets] = useState<PolicyFacets | null>(null);
|
||||||
|
|
||||||
@@ -122,7 +124,13 @@ function PolizasBrowser() {
|
|||||||
<>
|
<>
|
||||||
<div className="page-head rise">
|
<div className="page-head rise">
|
||||||
<p className="eyebrow">Cartera de seguros</p>
|
<p className="eyebrow">Cartera de seguros</p>
|
||||||
<h1 className="page-title">Pólizas</h1>
|
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||||
|
<h1 className="page-title" style={{ margin: 0 }}>Pólizas</h1>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
{canCreate && (
|
||||||
|
<Link href="/polizas/nuevo" className="btn btn-primary">+ Nueva póliza</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<StatStrip
|
<StatStrip
|
||||||
stats={stats}
|
stats={stats}
|
||||||
status={status}
|
status={status}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const NAV: { href: string; label: string; ability?: Ability }[] = [
|
|||||||
{ href: "/polizas", label: "Pólizas" },
|
{ href: "/polizas", label: "Pólizas" },
|
||||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||||
{ href: "/banco", label: "Chequera" },
|
{ href: "/banco", label: "Chequera" },
|
||||||
|
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
||||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/** A single editable field in a child row. */
|
||||||
|
export type FieldDef = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type?: "text" | "number" | "date" | "checkbox" | "select";
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
width?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChildConfig = {
|
||||||
|
/** URL segment: installments | vehicles | drivers | beneficiaries | claims */
|
||||||
|
apiKind: string;
|
||||||
|
title: string;
|
||||||
|
fields: FieldDef[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RowValues = Record<string, string | boolean>;
|
||||||
|
|
||||||
|
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<string, unknown>): 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<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>[];
|
||||||
|
canEdit: boolean;
|
||||||
|
onAdd: (payload: Record<string, unknown>) => Promise<void>;
|
||||||
|
onSave: (id: string, payload: Record<string, unknown>) => Promise<void>;
|
||||||
|
onRemove: (id: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [values, setValues] = useState<RowValues>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
function startAdd() {
|
||||||
|
setEditingId(null);
|
||||||
|
setAdding(true);
|
||||||
|
setValues(rowToValues(config.fields));
|
||||||
|
}
|
||||||
|
function startEdit(row: Record<string, unknown>) {
|
||||||
|
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 (
|
||||||
|
<div className="child-editor">
|
||||||
|
<div className="form-grid">
|
||||||
|
{config.fields.map((f) => (
|
||||||
|
<label className="field" key={f.key}>
|
||||||
|
<span className="field-label">{f.label}</span>
|
||||||
|
{f.type === "checkbox" ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!values[f.key]}
|
||||||
|
onChange={(e) => setValues({ ...values, [f.key]: e.target.checked })}
|
||||||
|
/>
|
||||||
|
) : f.type === "select" ? (
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={String(values[f.key] ?? "")}
|
||||||
|
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{f.options?.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type={f.type === "number" ? "number" : f.type === "date" ? "date" : "text"}
|
||||||
|
step={f.type === "number" ? "0.01" : undefined}
|
||||||
|
value={String(values[f.key] ?? "")}
|
||||||
|
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={submit} disabled={busy}>
|
||||||
|
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||||
|
<div className="child-head">
|
||||||
|
<h3 className="section-title" style={{ margin: 0 }}>
|
||||||
|
{config.title}
|
||||||
|
<span className="section-count"> {rows.length}</span>
|
||||||
|
</h3>
|
||||||
|
{canEdit && !adding && editingId === null && (
|
||||||
|
<button type="button" className="btn btn-outline" onClick={startAdd}>
|
||||||
|
+ Agregar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 && !adding ? (
|
||||||
|
<div className="empty-inline">Sin registros.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{config.fields.map((f) => (
|
||||||
|
<th key={f.key}>{f.label}</th>
|
||||||
|
))}
|
||||||
|
{canEdit && <th className="num">Acciones</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={String(row.id)}>
|
||||||
|
{config.fields.map((f) => (
|
||||||
|
<td key={f.key}>{cellText(f, row[f.key])}</td>
|
||||||
|
))}
|
||||||
|
{canEdit && (
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => startEdit(row)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => remove(String(row.id))}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(adding || editingId !== null) && editor()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<CustomerListItem[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const debounce = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="picker">
|
||||||
|
{value ? (
|
||||||
|
<div className="picker-selected">
|
||||||
|
<span>{valueName ?? "Cliente seleccionado"}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => onPick("", "")}
|
||||||
|
>
|
||||||
|
Cambiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="Buscar cliente por nombre…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onFocus={() => results.length && setOpen(true)}
|
||||||
|
/>
|
||||||
|
{open && results.length > 0 && (
|
||||||
|
<ul className="picker-list">
|
||||||
|
{results.map((c) => (
|
||||||
|
<li key={c.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="picker-item"
|
||||||
|
onClick={() => {
|
||||||
|
onPick(c.id, c.name);
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
{c.city && <span className="muted"> · {c.city}</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<V>(() => initial(policy));
|
||||||
|
const [lookups, setLookups] = useState<LookupsResponse | null>(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<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getLookups().then(setLookups).catch(() => setLookups(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function set<K extends keyof V>(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 (
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 14 }}>Datos de la póliza</h2>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Cliente *</span>
|
||||||
|
{editing ? (
|
||||||
|
<input className="input" value={customerName} disabled />
|
||||||
|
) : (
|
||||||
|
<CustomerPicker
|
||||||
|
value={customerId}
|
||||||
|
valueName={customerName}
|
||||||
|
onPick={(id, name) => {
|
||||||
|
setCustomerId(id);
|
||||||
|
setCustomerName(name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Número de póliza *</span>
|
||||||
|
<input className="input" required value={v.policyNumber}
|
||||||
|
onChange={(e) => set("policyNumber", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Tipo</span>
|
||||||
|
<select className="select" value={v.policyTypeId}
|
||||||
|
onChange={(e) => set("policyTypeId", e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{lookups?.types.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Aseguradora</span>
|
||||||
|
<select className="select" value={v.insuranceProviderId}
|
||||||
|
onChange={(e) => set("insuranceProviderId", e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{lookups?.providers.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Agente</span>
|
||||||
|
<input className="input" value={v.agentName}
|
||||||
|
onChange={(e) => set("agentName", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Moneda</span>
|
||||||
|
<select className="select" value={v.currency}
|
||||||
|
onChange={(e) => set("currency", e.target.value as Currency)}>
|
||||||
|
<option value="MXN">MXN</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia y prima</h2>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Emisión</span>
|
||||||
|
<input className="input" type="date" value={v.policyDate}
|
||||||
|
onChange={(e) => set("policyDate", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Desde</span>
|
||||||
|
<input className="input" type="date" value={v.policyFrom}
|
||||||
|
onChange={(e) => set("policyFrom", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Hasta</span>
|
||||||
|
<input className="input" type="date" value={v.policyTo}
|
||||||
|
onChange={(e) => set("policyTo", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Prima neta</span>
|
||||||
|
<input className="input" type="number" step="0.01" value={v.netPremium}
|
||||||
|
onChange={(e) => set("netPremium", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Derecho de póliza</span>
|
||||||
|
<input className="input" type="number" step="0.01" value={v.policyFee}
|
||||||
|
onChange={(e) => set("policyFee", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Comisión</span>
|
||||||
|
<input className="input" type="number" step="0.01" value={v.commission}
|
||||||
|
onChange={(e) => set("commission", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 14 }}>Liquidación</h2>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Liquidada</span>
|
||||||
|
<input type="checkbox" checked={v.liquidated}
|
||||||
|
onChange={(e) => set("liquidated", e.target.checked)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Endoso</span>
|
||||||
|
<input type="checkbox" checked={v.endorsement}
|
||||||
|
onChange={(e) => set("endorsement", e.target.checked)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">No. liquidación</span>
|
||||||
|
<input className="input" value={v.liquidationNumber}
|
||||||
|
onChange={(e) => set("liquidationNumber", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Fecha liquidación</span>
|
||||||
|
<input className="input" type="date" value={v.liquidationDate}
|
||||||
|
onChange={(e) => set("liquidationDate", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field" style={{ marginTop: 14 }}>
|
||||||
|
<span className="field-label">Observaciones</span>
|
||||||
|
<textarea className="input" rows={2} value={v.observations}
|
||||||
|
onChange={(e) => set("observations", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field" style={{ marginTop: 12 }}>
|
||||||
|
<span className="field-label">Notas</span>
|
||||||
|
<textarea className="input" rows={2} value={v.notes}
|
||||||
|
onChange={(e) => set("notes", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||||
|
{saving ? "Guardando…" : editing ? "Guardar cambios" : "Crear póliza"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => router.back()}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,10 +26,12 @@ import type {
|
|||||||
MovementSort,
|
MovementSort,
|
||||||
PolicyDetail,
|
PolicyDetail,
|
||||||
PolicyFacets,
|
PolicyFacets,
|
||||||
|
PolicyInput,
|
||||||
PolicyListResponse,
|
PolicyListResponse,
|
||||||
PolicySort,
|
PolicySort,
|
||||||
PolicyStats,
|
PolicyStats,
|
||||||
PolicyStatus,
|
PolicyStatus,
|
||||||
|
LookupsResponse,
|
||||||
PropertyDetail,
|
PropertyDetail,
|
||||||
PropertyFacets,
|
PropertyFacets,
|
||||||
PropertyListResponse,
|
PropertyListResponse,
|
||||||
@@ -208,6 +210,83 @@ export function getPolicy(
|
|||||||
return apiFetch<PolicyDetail>(`/policies/${id}?days=${days}`);
|
return apiFetch<PolicyDetail>(`/policies/${id}?days=${days}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createPolicy(input: PolicyInput): Promise<PolicyDetail> {
|
||||||
|
return apiFetch<PolicyDetail>("/policies", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function updatePolicy(
|
||||||
|
id: string,
|
||||||
|
input: Partial<PolicyInput>,
|
||||||
|
): Promise<PolicyDetail> {
|
||||||
|
return apiFetch<PolicyDetail>(`/policies/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function archivePolicy(id: string): Promise<PolicyDetail> {
|
||||||
|
return apiFetch<PolicyDetail>(`/policies/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
export function restorePolicy(id: string): Promise<PolicyDetail> {
|
||||||
|
return apiFetch<PolicyDetail>(`/policies/${id}/restore`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic policy-child CRUD. `kind` is the URL segment
|
||||||
|
// (installments|vehicles|drivers|beneficiaries|claims).
|
||||||
|
export function addPolicyChild<T>(
|
||||||
|
policyId: string,
|
||||||
|
kind: string,
|
||||||
|
input: T,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`/policies/${policyId}/${kind}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function updatePolicyChild<T>(
|
||||||
|
policyId: string,
|
||||||
|
kind: string,
|
||||||
|
childId: string,
|
||||||
|
input: T,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`/policies/${policyId}/${kind}/${childId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function removePolicyChild(
|
||||||
|
policyId: string,
|
||||||
|
kind: string,
|
||||||
|
childId: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`/policies/${policyId}/${kind}/${childId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------- Lookups (insurance ref) */
|
||||||
|
|
||||||
|
export function getLookups(): Promise<LookupsResponse> {
|
||||||
|
return apiFetch<LookupsResponse>("/lookups");
|
||||||
|
}
|
||||||
|
export function createLookup(kind: string, input: unknown): Promise<unknown> {
|
||||||
|
return apiFetch(`/lookups/${kind}`, { method: "POST", body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
export function updateLookup(
|
||||||
|
kind: string,
|
||||||
|
id: string,
|
||||||
|
input: unknown,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`/lookups/${kind}/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function removeLookup(kind: string, id: string): Promise<unknown> {
|
||||||
|
return apiFetch(`/lookups/${kind}/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------- Utilities module */
|
/* ----------------------------------------------------- Utilities module */
|
||||||
|
|
||||||
export interface PropertyQuery {
|
export interface PropertyQuery {
|
||||||
|
|||||||
+112
-1
@@ -155,17 +155,23 @@ export interface Vehicle {
|
|||||||
id: string;
|
id: string;
|
||||||
make: string | null;
|
make: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
modelYear: number | null;
|
modelYear: number | string | null;
|
||||||
licensePlate: string | null;
|
licensePlate: string | null;
|
||||||
bodyType: string | null;
|
bodyType: string | null;
|
||||||
engineNumber?: string | null;
|
engineNumber?: string | null;
|
||||||
vinNumber?: string | null;
|
vinNumber?: string | null;
|
||||||
|
stateCode?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InsuredDriver {
|
export interface InsuredDriver {
|
||||||
id: string;
|
id: string;
|
||||||
fullName: string | null;
|
fullName: string | null;
|
||||||
licenseNumber: string | null;
|
licenseNumber: string | null;
|
||||||
|
birthDate?: string | null;
|
||||||
|
sex?: string | null;
|
||||||
|
occupation?: string | null;
|
||||||
|
licenseState?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Beneficiary {
|
export interface Beneficiary {
|
||||||
@@ -237,6 +243,106 @@ export interface PolicyListItem {
|
|||||||
vehicleCount: number;
|
vehicleCount: number;
|
||||||
installmentCount: number;
|
installmentCount: number;
|
||||||
documentCount: number;
|
documentCount: number;
|
||||||
|
archived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editable policy-header fields — shared by the create/edit form and API. */
|
||||||
|
export interface PolicyInput {
|
||||||
|
policyNumber: string;
|
||||||
|
customerId: string;
|
||||||
|
policyTypeId?: string;
|
||||||
|
insuranceProviderId?: string;
|
||||||
|
agentName?: string;
|
||||||
|
policyDate?: string;
|
||||||
|
policyFrom?: string;
|
||||||
|
policyTo?: string;
|
||||||
|
coveragePeriodDays?: number;
|
||||||
|
netPremium?: number;
|
||||||
|
policyFee?: number;
|
||||||
|
brokerFee?: number;
|
||||||
|
commission?: number;
|
||||||
|
total?: number;
|
||||||
|
currency?: Currency;
|
||||||
|
observations?: string;
|
||||||
|
notes?: string;
|
||||||
|
endorsement?: boolean;
|
||||||
|
liquidated?: boolean;
|
||||||
|
liquidationNumber?: string;
|
||||||
|
liquidationDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstallmentInput {
|
||||||
|
sequence: number;
|
||||||
|
amount?: number;
|
||||||
|
currency?: Currency;
|
||||||
|
dueDate?: string;
|
||||||
|
paidDate?: string;
|
||||||
|
checkNumber?: string;
|
||||||
|
isCash?: boolean;
|
||||||
|
}
|
||||||
|
export interface VehicleInput {
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
modelYear?: string;
|
||||||
|
bodyType?: string;
|
||||||
|
engineNumber?: string;
|
||||||
|
licensePlate?: string;
|
||||||
|
vinNumber?: string;
|
||||||
|
stateCode?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
export interface DriverInput {
|
||||||
|
fullName?: string;
|
||||||
|
birthDate?: string;
|
||||||
|
sex?: string;
|
||||||
|
occupation?: string;
|
||||||
|
licenseNumber?: string;
|
||||||
|
licenseState?: string;
|
||||||
|
}
|
||||||
|
export interface BeneficiaryInput {
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
export interface ClaimInput {
|
||||||
|
claimType?: string;
|
||||||
|
incidentDate?: string;
|
||||||
|
reportedDate?: string;
|
||||||
|
description?: string;
|
||||||
|
adjusterId?: string;
|
||||||
|
claimedAmount?: number;
|
||||||
|
settledAmount?: number;
|
||||||
|
settlementDate?: string;
|
||||||
|
checkNumber?: string;
|
||||||
|
resolved?: boolean;
|
||||||
|
resolutionNotes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lookups (insurance reference data) */
|
||||||
|
export interface ProviderRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
_count?: { policies: number };
|
||||||
|
}
|
||||||
|
export interface PolicyTypeRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shortDescription: string | null;
|
||||||
|
_count?: { policies: number };
|
||||||
|
}
|
||||||
|
export interface AdjusterRow {
|
||||||
|
id: string;
|
||||||
|
company: string | null;
|
||||||
|
city: string | null;
|
||||||
|
name: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
beeper: string | null;
|
||||||
|
}
|
||||||
|
export interface LookupsResponse {
|
||||||
|
providers: ProviderRow[];
|
||||||
|
types: PolicyTypeRow[];
|
||||||
|
adjusters: AdjusterRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyListResponse {
|
export interface PolicyListResponse {
|
||||||
@@ -295,6 +401,10 @@ export interface Claim {
|
|||||||
settlementDate: string | null;
|
settlementDate: string | null;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
adjuster: Adjuster | null;
|
adjuster: Adjuster | null;
|
||||||
|
adjusterId?: string | null;
|
||||||
|
checkNumber?: string | null;
|
||||||
|
resolved?: boolean;
|
||||||
|
resolutionNotes?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyCustomerRef {
|
export interface PolicyCustomerRef {
|
||||||
@@ -333,6 +443,7 @@ export interface PolicyDetail {
|
|||||||
legacySourceDb: string | null;
|
legacySourceDb: string | null;
|
||||||
legacySourceTable: string | null;
|
legacySourceTable: string | null;
|
||||||
legacyId: string | null;
|
legacyId: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
status: PolicyStatus;
|
status: PolicyStatus;
|
||||||
daysToExpiry: number | null;
|
daysToExpiry: number | null;
|
||||||
customer: PolicyCustomerRef;
|
customer: PolicyCustomerRef;
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ model Policy {
|
|||||||
liquidated Boolean @default(false)
|
liquidated Boolean @default(false)
|
||||||
liquidationNumber String?
|
liquidationNumber String?
|
||||||
liquidationDate DateTime?
|
liquidationDate DateTime?
|
||||||
|
// Soft-delete marker (see Customer.archivedAt). Never hard-delete migrated
|
||||||
|
// policy data; archiving hides it from default lists.
|
||||||
|
archivedAt DateTime?
|
||||||
legacySourceDb String?
|
legacySourceDb String?
|
||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
legacyId String?
|
legacyId String?
|
||||||
|
|||||||
Reference in New Issue
Block a user