Compare commits

...
7 Commits
Author SHA1 Message Date
rmancinasandClaude Opus 4.8 0260b8110d fix(catalogos): render child editor in-place of edited row
The add/edit form was appended after the whole table, so on long lists
(e.g. /catalogos aseguradoras, 16+ rows) clicking Editar on a top row
opened the form far below the fold — appearing to do nothing. Render the
edit form in place of its row, and the add form as the first table row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 17:42:41 -07:00
rmancinasandClaude Opus 4.8 7d9f59e51b feat(billing,bank): capture + void web UI (plan phase 5 web)
Completes phase 5 — the ledger and chequera pages get the append+void
UI on top of the phase-5 API.

Web:
- Shared MovementForm (customer picker + línea + cargo/abono sign + amount
  + moneda + concepto facet + periodo/referencia/cheque/mensaje). Used by
  both /estado-cuenta (cross-customer, picker) and /estado-cuenta/[id]
  (customer prefilled).
- /estado-cuenta and /estado-cuenta/[id]: "Capturar movimiento" toggle
  gated ledger:create; per-row "Anular" gated ledger:void; voided rows
  struck-through. Save/void refresh the list + stats.
- /banco: inline BankCaptureForm (ingreso/egreso sign, cheque, operado,
  transferencia, monto en letras) gated bank:create; per-row "Anular"
  gated bank:void; voided rows struck-through.
- api.ts: createMovement/voidMovement, createBankMovement/voidBankMovement;
  CreateMovementInput/CreateBankMovementInput types; `voided` on the
  movement/statement/bank list items.

Also: lookups.controller.ts now audit-logs provider/policy-type/adjuster
create/update/delete (parity with the other write controllers).

API + web compile clean. This is the last piece of the feat/crud-rbac
branch — all five sections plus users are now full CRUD with role gating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 17:17:47 -07:00
rmancinasandClaude Opus 4.8 548eeb5798 feat(ledger,bank): append + void write API, voided excluded from totals (plan phase 5 API)
Transactions and the bank register become append-only with a void
(reversal) action — never edited or hard-deleted. This is the API half of
phase 5; the capture/void web UI is the remaining piece.

Schema:
- Transaction and BankTransaction gain voidedAt + voidedById. A non-null
  voidedAt reverses the row. Pushed to dev.

Correctness (the high-stakes part):
- Every aggregate excludes voided rows: billing movements totals, the raw
  balances SQL, stats (groupBy + the sides/crossLine raw subqueries +
  first/last), facets (types/sources/years); the statement's running
  balance freezes on a voided row and its per-currency/per-domain/per-type
  summaries skip them; customers.detail and property owner-ledger groupBy;
  and every bank total (totalsFor, stats counts/bounds, facets + summary
  raw SQL). List views still return voided rows with a `voided` flag so
  the UI can strike them through.
- Bank's legacy zero-amount "void" cheques are unchanged and distinct from
  app voids (voidedAt).

API:
- POST /billing + POST /billing/:id/void (ledger:create / ledger:void);
  POST /bank + POST /bank/:id/void (bank:create / bank:void). Create needs
  STAFF+, void needs MANAGER+. Double-void -> 400, unknown id -> 404,
  bad date -> 400. Mutations audited. DTOs added.

Verified against dev end-to-end: a -500 MXN charge moved a customer
balance 31082.08 -> 30582.08, and voiding it returned it to 31082.08 to
the cent; a +1234.56 bank ingreso moved net 899375.77 -> 900610.33 and
voiding returned it to 899375.77. VIEWER create/void both 403,
double-void 400. API compiles clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:34:47 -07:00
rmancinasandClaude Opus 4.8 506f8ce684 feat(properties): CRUD + service/trust/document editors (plan phase 4)
Utilities section becomes create/edit/archive-able, with its child data.

API:
- Property gains archivedAt (soft-delete); list/browser default to
  archivedAt=null with ?includeArchived opt-in.
- PropertiesService: header create/update/archive/restore (customer FK
  validated); PropertyService add/update/remove scoped to the property;
  TrustAccount upsert (1:1) + remove; ServiceDocument pointer delete.
- Controller write routes: create needs STAFF+ (property:create), archive
  MANAGER+ (property:delete), every service/trust/document route
  property:update. Mutations audited. DTOs added.
- Document *upload* deliberately deferred: it needs the object-storage
  client wired into the API (today only the migration writes to MinIO);
  removing an existing pointer row is supported and the UI says so.

Web:
- PropertyForm (header) with CustomerPicker; /servicios/nuevo (accepts
  ?customerId prefill) and /servicios/[id]/editar.
- Property detail: gated action bar (Editar/Archivar) + "Administrar
  propiedad" — services via the shared ChildCollection editor, an inline
  1:1 TrustEditor (create/update/clear), and document-row delete.
- "Nueva propiedad" buttons on the list and customer detail (prefilled).
  api.ts + types for all of it.

Verified against dev: property create (archivedAt null), service
add/update, VIEWER service-add 403, trust upsert (create then update the
same row), trust/service remove, cross-property child guard 404, archive
drops from the default list and includeArchived surfaces it. Both apps
compile clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:27:53 -07:00
rmancinasandClaude Opus 4.8 7a46c30d9b 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>
2026-07-23 12:22:02 -07:00
rmancinasandClaude Opus 4.8 12692a0af8 feat(customers): create/edit/archive CRUD with soft-delete (plan phase 2)
First master-data CRUD module on the phase-1 RBAC foundation.

API:
- Customer gains archivedAt (soft-delete marker, distinct from the legacy
  `status` business flag); pushed to dev (nullable, non-destructive).
- CustomersService: create/update/archive/restore. list() and the browser
  default to archivedAt=null; ?includeArchived=true opts in. App-created
  rows set nameMissing=false and leave legacy provenance null.
- CustomersController write routes guarded per the matrix: create/update
  need STAFF+ (customer:create/update), archive/restore need ADMIN
  (customer:delete). Every mutation audit-logged.
- create/update DTOs (class-validator); date strings coerced to Date.

Web:
- Shared CustomerForm (create + edit) with identity/address/account
  sections; new routes /clientes/nuevo and /clientes/[id]/editar, each
  self-gated on the ability.
- List page: ability-gated "Nuevo cliente" button. Detail page: gated
  Editar / Archivar (Restaurar) action bar; archived badge.
- api.ts create/update/archive/restore; CustomerInput type; archived flag
  on list items.

Verified against dev: create (dates coerced, archivedAt null), edit 200,
VIEWER create 403, STAFF create 201 but archive 403, ADMIN archive drops
the row from the default list and includeArchived surfaces it, restore
returns it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:08:44 -07:00
rmancinasandClaude Opus 4.8 74e2ad8bcd feat(auth): role-based permissions + user management (plan phase 1)
Adds the RBAC foundation the CRUD phases build on, and the first write
module (users). The platform was read-only: every controller was guarded
only by AuthenticatedGuard and UserRole was ADMIN|STAFF. The old PHP app
stored level+role but enforced neither, so this is a fresh design.

Permission model (server-authoritative):
- UserRole expanded to an ordered rank ADMIN > MANAGER > STAFF > VIEWER.
  VIEWER is the read-only role; STAFF+ can write.
- auth/abilities.ts: ROLE_RANK + ABILITY_MIN matrix + can()/abilitiesFor().
- @RequireAbility decorator + AbilityGuard enforce it on write routes;
  reads stay on AuthenticatedGuard so any logged-in user can read.
- /auth/login and /auth/me now return the resolved abilities map, so the
  web gates its UI off one payload instead of duplicating the rules.

User management (ADMIN-only, ability "user:manage"):
- UsersService gains list/create/update/resetPassword (argon2), never
  returns passwordHash; blocks self-deactivation and self-demotion;
  maps duplicate email to 409.
- UsersController: GET/POST /users, PATCH /users/:id,
  POST /users/:id/reset-password.
- Every mutation logged via new AuditService over the existing
  ActivityLog model (global CommonModule).

Web:
- AuthContext + useAuth/useCan; AppShell provides the user and gates the
  new "Usuarios" nav entry on user:manage; shows the user's role.
- /usuarios admin page: list + create/edit form + password reset +
  active toggle, Spanish-first, reusing existing card/table/field styles.

Schema pushed to dev (enum only, non-destructive). Verified end-to-end
against dev: admin CRUD works, VIEWER writes 403 while reads 200,
self-lockout guards and duplicate-email 409 all hold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:02:00 -07:00
64 changed files with 5704 additions and 113 deletions
+2
View File
@@ -1,6 +1,7 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "./prisma/prisma.module";
import { CommonModule } from "./common/common.module";
import { UsersModule } from "./users/users.module";
import { AuthModule } from "./auth/auth.module";
import { CustomersModule } from "./customers/customers.module";
@@ -14,6 +15,7 @@ import { AppController } from "./app.controller";
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
CommonModule,
UsersModule,
AuthModule,
CustomersModule,
+67
View File
@@ -0,0 +1,67 @@
// Server-authoritative permission matrix. Roles form an ordered rank
// (ADMIN > MANAGER > STAFF > VIEWER — this is the "level" concept); every
// write action carries a minimum rank. VIEWER holds rank 0 and is the
// read-only role. Reads are not listed here — they stay on AuthenticatedGuard
// alone, so any logged-in user (including VIEWER) can read.
//
// This is the single source of truth: the API enforces it via AbilityGuard and
// ships the resolved per-user map to the web through /auth/me, so the UI never
// keeps its own copy of the rules.
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
export const ROLE_RANK: Record<Role, number> = {
VIEWER: 0,
STAFF: 1,
MANAGER: 2,
ADMIN: 3,
};
export type Ability =
| "customer:create"
| "customer:update"
| "customer:delete"
| "policy:create"
| "policy:update"
| "policy:delete"
| "property:create"
| "property:update"
| "property:delete"
| "ledger:create"
| "ledger:void"
| "bank:create"
| "bank:void"
| "lookup:manage"
| "user:manage";
/** Minimum role required for each ability. */
export const ABILITY_MIN: Record<Ability, Role> = {
"customer:create": "STAFF",
"customer:update": "STAFF",
"customer:delete": "ADMIN",
"policy:create": "STAFF",
"policy:update": "STAFF",
"policy:delete": "MANAGER",
"property:create": "STAFF",
"property:update": "STAFF",
"property:delete": "MANAGER",
"ledger:create": "STAFF",
"ledger:void": "MANAGER",
"bank:create": "STAFF",
"bank:void": "MANAGER",
"lookup:manage": "MANAGER",
"user:manage": "ADMIN",
};
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
export function can(role: Role, ability: Ability): boolean {
return ROLE_RANK[role] >= ROLE_RANK[ABILITY_MIN[ability]];
}
/** Resolved {ability: boolean} map for a role — sent to the web via /auth/me. */
export function abilitiesFor(role: Role): Record<Ability, boolean> {
return Object.fromEntries(
ALL_ABILITIES.map((a) => [a, can(role, a)]),
) as Record<Ability, boolean>;
}
+36
View File
@@ -0,0 +1,36 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { ABILITY_KEY } from "./require-ability.decorator";
import { Ability, Role, can } from "./abilities";
/**
* Enforces the ability matrix (abilities.ts) against req.user.role. A route
* with no @RequireAbility passes through untouched — this guard only gates the
* routes that declare one. It does NOT check authentication; always list it
* after AuthenticatedGuard so an unauthenticated request is rejected first.
*/
@Injectable()
export class AbilityGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const ability = this.reflector.getAllAndOverride<Ability | undefined>(
ABILITY_KEY,
[context.getHandler(), context.getClass()],
);
if (!ability) return true;
const req = context.switchToHttp().getRequest<Request>();
const user = req.user as { role?: Role } | undefined;
if (!user?.role || !can(user.role, ability)) {
throw new ForbiddenException("No tiene permisos para esta acción");
}
return true;
}
}
+10 -2
View File
@@ -3,6 +3,14 @@ import { Request, Response } from "express";
import { LocalAuthGuard } from "./local-auth.guard";
import { AuthenticatedGuard } from "./authenticated.guard";
import { LoginDto } from "./login.dto";
import { abilitiesFor, Role } from "./abilities";
/** Attach the resolved ability map so the web can gate its UI off one payload. */
function withAbilities(user: unknown) {
const u = user as { role?: Role } | undefined;
if (!u?.role) return u;
return { ...u, abilities: abilitiesFor(u.role) };
}
@Controller("auth")
export class AuthController {
@@ -13,13 +21,13 @@ export class AuthController {
@Post("login")
@HttpCode(200)
login(@Req() req: Request, @Res({ passthrough: true }) _res: Response, _body?: LoginDto) {
return req.user;
return withAbilities(req.user);
}
@UseGuards(AuthenticatedGuard)
@Get("me")
me(@Req() req: Request) {
return req.user;
return withAbilities(req.user);
}
@Post("logout")
@@ -0,0 +1,12 @@
import { SetMetadata } from "@nestjs/common";
import type { Ability } from "./abilities";
export const ABILITY_KEY = "required_ability";
/**
* Tags a write route with the ability it requires. Pair with
* `@UseGuards(AuthenticatedGuard, AbilityGuard)` — AuthenticatedGuard proves
* the session, AbilityGuard checks this ability against the user's role.
*/
export const RequireAbility = (ability: Ability) =>
SetMetadata(ABILITY_KEY, ability);
+19
View File
@@ -0,0 +1,19 @@
import { IsBoolean, IsNumber, IsOptional, IsString, MinLength } from "class-validator";
/**
* A new bank-register movement. `amount` is signed: positive = ingreso,
* negative = egreso (the module's sign convention). Single currency (MXN).
* Booked rows are never edited — a mistake is corrected by voiding + re-capture.
*/
export class CreateBankMovementDto {
@IsNumber() amount!: number;
@IsString() @MinLength(1) transactionDate!: string;
@IsOptional() @IsString() concept?: string;
@IsOptional() @IsString() reference?: string;
@IsOptional() @IsString() transactionType?: string;
@IsOptional() @IsBoolean() cleared?: boolean;
@IsOptional() @IsBoolean() transferred?: boolean;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsString() amountInWords?: string;
}
+45 -3
View File
@@ -1,11 +1,25 @@
import { Controller, Get, Query, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Get,
Param,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import {
BankCleared,
BankDirection,
BankService,
BankSort,
} from "./bank.service";
import { CreateBankMovementDto } from "./bank-movement.dto";
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
const CLEARED: BankCleared[] = ["cleared", "pending"];
@@ -28,10 +42,17 @@ function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
return Number.isNaN(d.getTime()) ? undefined : d;
}
@UseGuards(AuthenticatedGuard)
@UseGuards(AuthenticatedGuard, AbilityGuard)
@Controller("bank")
export class BankController {
constructor(private readonly bank: BankService) {}
constructor(
private readonly bank: BankService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get("stats")
stats() {
@@ -75,4 +96,25 @@ export class BankController {
sort: one(SORTS, sort) ?? "date_desc",
});
}
// --- writes ---------------------------------------------------------------
@Post()
@RequireAbility("bank:create")
async create(@Body() dto: CreateBankMovementDto, @Req() req: Request) {
const row = await this.bank.createMovement(dto);
void this.audit.log(this.actingId(req), "bank.create", {
bankTransactionId: row.id,
amount: dto.amount,
});
return row;
}
@Post(":id/void")
@RequireAbility("bank:void")
async void(@Param("id") id: string, @Req() req: Request) {
const row = await this.bank.voidMovement(id, this.actingId(req));
void this.audit.log(this.actingId(req), "bank.void", { bankTransactionId: id });
return row;
}
}
+59 -8
View File
@@ -1,6 +1,15 @@
import { Injectable } from "@nestjs/common";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CreateBankMovementDto } from "./bank-movement.dto";
/**
* App-voided rows (voidedAt set) are reversed and must leave every
* income/expense/net total. This is distinct from the legacy zero-amount
* "void" cheques, which stay as amount-0 rows. List views still show voided
* rows struck-through.
*/
const NOT_VOIDED: Prisma.BankTransactionWhereInput = { voidedAt: null };
/**
* Bank register (chequera) module — plan step 7.
@@ -160,6 +169,7 @@ export class BankService {
notes: true,
amountInWords: true,
legacySourceTable: true,
voidedAt: true,
},
}),
]);
@@ -182,6 +192,7 @@ export class BankService {
notes: r.notes,
amountInWords: r.amountInWords,
source: r.legacySourceTable,
voided: r.voidedAt != null,
})),
total,
page: params.page,
@@ -195,17 +206,17 @@ export class BankService {
private async totalsFor(where: Prisma.BankTransactionWhereInput) {
const [income, expense, voided] = await Promise.all([
this.prisma.bankTransaction.aggregate({
where: { AND: [where, { amount: { gt: 0 } }] },
where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
}),
this.prisma.bankTransaction.aggregate({
where: { AND: [where, { amount: { lt: 0 } }] },
where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
}),
this.prisma.bankTransaction.count({
where: { AND: [where, { amount: 0 }] },
where: { AND: [where, { amount: 0 }, NOT_VOIDED] },
}),
]);
@@ -225,13 +236,18 @@ export class BankService {
/** Top-line figures for the bank page header. */
async stats() {
const [count, bounds, pending, transferred, totals] = await Promise.all([
this.prisma.bankTransaction.count(),
this.prisma.bankTransaction.count({ where: NOT_VOIDED }),
this.prisma.bankTransaction.aggregate({
where: NOT_VOIDED,
_min: { transactionDate: true },
_max: { transactionDate: true },
}),
this.prisma.bankTransaction.count({ where: { cleared: false } }),
this.prisma.bankTransaction.count({ where: { transferred: true } }),
this.prisma.bankTransaction.count({
where: { AND: [{ cleared: false }, NOT_VOIDED] },
}),
this.prisma.bankTransaction.count({
where: { AND: [{ transferred: true }, NOT_VOIDED] },
}),
this.totalsFor({}),
]);
@@ -252,6 +268,7 @@ export class BankService {
>`
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
FROM bank_transactions
WHERE voidedAt IS NULL
GROUP BY year
ORDER BY year DESC
`;
@@ -280,6 +297,7 @@ export class BankService {
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
SUM(amount) AS net
FROM bank_transactions
WHERE voidedAt IS NULL
GROUP BY period
ORDER BY period ASC
`;
@@ -293,7 +311,7 @@ export class BankService {
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
SUM(amount) AS net
FROM bank_transactions
WHERE YEAR(transactionDate) = ${year}
WHERE YEAR(transactionDate) = ${year} AND voidedAt IS NULL
GROUP BY period
ORDER BY period ASC
`
@@ -346,6 +364,39 @@ export class BankService {
opening: opening.toFixed(2),
};
}
// --- writes (append + void) -----------------------------------------------
async createMovement(dto: CreateBankMovementDto) {
const date = new Date(dto.transactionDate);
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
return this.prisma.bankTransaction.create({
data: {
amount: dto.amount,
transactionDate: date,
concept: dto.concept,
reference: dto.reference,
transactionType: dto.transactionType,
cleared: dto.cleared ?? false,
transferred: dto.transferred ?? false,
notes: dto.notes,
amountInWords: dto.amountInWords,
},
});
}
async voidMovement(id: string, userId: string) {
const row = await this.prisma.bankTransaction.findUnique({
where: { id },
select: { id: true, voidedAt: true },
});
if (!row) throw new NotFoundException(`Bank transaction ${id} not found`);
if (row.voidedAt) throw new BadRequestException("El movimiento ya está anulado");
return this.prisma.bankTransaction.update({
where: { id },
data: { voidedAt: new Date(), voidedById: userId },
});
}
}
function directionOf(amount: Prisma.Decimal): BankDirection {
+47 -3
View File
@@ -1,6 +1,19 @@
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Get,
Param,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { TransactionDomain } from "@jorgecuadros/database";
import { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import {
BalanceFilter,
BalanceSort,
@@ -9,6 +22,7 @@ import {
LedgerDirection,
MovementSort,
} from "./billing.service";
import { CreateMovementDto } from "./movement.dto";
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
@@ -39,10 +53,17 @@ function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
return Number.isNaN(d.getTime()) ? undefined : d;
}
@UseGuards(AuthenticatedGuard)
@UseGuards(AuthenticatedGuard, AbilityGuard)
@Controller("billing")
export class BillingController {
constructor(private readonly billing: BillingService) {}
constructor(
private readonly billing: BillingService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get("stats")
stats() {
@@ -113,4 +134,27 @@ export class BillingController {
sort: one(MOVEMENT_SORTS, sort) ?? "date_desc",
});
}
// --- writes ---------------------------------------------------------------
@Post()
@RequireAbility("ledger:create")
async create(@Body() dto: CreateMovementDto, @Req() req: Request) {
const tx = await this.billing.createMovement(dto);
void this.audit.log(this.actingId(req), "ledger.create", {
transactionId: tx.id,
customerId: dto.customerId,
amount: dto.amount,
currency: tx.currency,
});
return tx;
}
@Post(":id/void")
@RequireAbility("ledger:void")
async void(@Param("id") id: string, @Req() req: Request) {
const tx = await this.billing.voidMovement(id, this.actingId(req));
void this.audit.log(this.actingId(req), "ledger.void", { transactionId: id });
return tx;
}
}
+81 -14
View File
@@ -1,6 +1,7 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, TransactionDomain } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CreateMovementDto } from "./movement.dto";
/**
* Shared billing / statements module — plan step 6.
@@ -104,6 +105,13 @@ function dec(v: Prisma.Decimal | null | undefined): string {
return (v ?? new Prisma.Decimal(0)).toFixed(2);
}
/**
* Every aggregate (SUM/count/groupBy and the raw balance SQL) must exclude
* voided rows, or a reversed movement keeps affecting the books. List views
* still show voided rows struck-through — only totals drop them.
*/
const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
@@ -187,6 +195,7 @@ export class BillingService {
checkNumber: true,
message: true,
legacySourceTable: true,
voidedAt: true,
type: { select: { nameEn: true, nameEs: true } },
customer: {
select: { id: true, name: true, nameSource: true, city: true },
@@ -200,19 +209,19 @@ export class BillingService {
// cover everything the filter matched.
const totals = await this.prisma.transaction.groupBy({
by: ["currency"],
where,
where: { AND: [where, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
});
const charges = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { AND: [where, { amount: { lt: 0 } }] },
where: { AND: [where, { amount: { lt: 0 } }, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
});
const credits = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { AND: [where, { amount: { gt: 0 } }] },
where: { AND: [where, { amount: { gt: 0 } }, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
});
@@ -233,6 +242,7 @@ export class BillingService {
message: r.message,
source: r.legacySourceTable,
type: r.type,
voided: r.voidedAt != null,
customerId: r.customer.id,
customerName: r.customer.name,
customerNameSource: r.customer.nameSource,
@@ -326,7 +336,7 @@ export class BillingService {
MAX(t.transactionDate) AS lastMovement
FROM customers c
JOIN transactions t ON t.customerId = c.id
WHERE 1 = 1 ${nameFilter} ${txFilter}
WHERE t.voidedAt IS NULL ${nameFilter} ${txFilter}
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
${having}
${orderBy}
@@ -382,17 +392,23 @@ export class BillingService {
/** Top-line figures for the billing page header. */
async stats() {
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
this.prisma.transaction.count(),
this.prisma.transaction.count({ where: NOT_VOIDED }),
this.prisma.transaction
.findMany({ distinct: ["customerId"], select: { customerId: true } })
.findMany({
where: NOT_VOIDED,
distinct: ["customerId"],
select: { customerId: true },
})
.then((r) => r.length),
this.prisma.transaction.groupBy({
by: ["currency"],
where: NOT_VOIDED,
_sum: { amount: true },
_count: { _all: true },
}),
this.prisma.transaction.groupBy({
by: ["domain", "currency"],
where: NOT_VOIDED,
_sum: { amount: true },
_count: { _all: true },
}),
@@ -400,13 +416,13 @@ export class BillingService {
const charges = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { amount: { lt: 0 } },
where: { AND: [{ amount: { lt: 0 } }, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
});
const credits = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { amount: { gt: 0 } },
where: { AND: [{ amount: { gt: 0 } }, NOT_VOIDED] },
_sum: { amount: true },
_count: { _all: true },
});
@@ -428,7 +444,7 @@ export class BillingService {
SUM(bal > 0.005) AS inCredit
FROM (
SELECT customerId, currency, SUM(amount) AS bal
FROM transactions GROUP BY customerId, currency
FROM transactions WHERE voidedAt IS NULL GROUP BY customerId, currency
) x
GROUP BY currency
`;
@@ -436,10 +452,12 @@ export class BillingService {
const [firstRow, lastRow] = await Promise.all([
this.prisma.transaction.findFirst({
where: NOT_VOIDED,
orderBy: { transactionDate: "asc" },
select: { transactionDate: true },
}),
this.prisma.transaction.findFirst({
where: NOT_VOIDED,
orderBy: { transactionDate: "desc" },
select: { transactionDate: true },
}),
@@ -449,7 +467,7 @@ export class BillingService {
// module is one view instead of two.
const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>`
SELECT COUNT(*) AS n FROM (
SELECT customerId FROM transactions
SELECT customerId FROM transactions WHERE voidedAt IS NULL
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
) x
`;
@@ -484,7 +502,7 @@ export class BillingService {
async facets() {
const types = await this.prisma.transaction.groupBy({
by: ["typeId"],
where: { typeId: { not: null } },
where: { AND: [{ typeId: { not: null } }, NOT_VOIDED] },
_count: { _all: true },
orderBy: { _count: { typeId: "desc" } },
});
@@ -496,6 +514,7 @@ export class BillingService {
const sources = await this.prisma.transaction.groupBy({
by: ["legacySourceTable"],
where: NOT_VOIDED,
_count: { _all: true },
orderBy: { _count: { legacySourceTable: "desc" } },
});
@@ -504,7 +523,7 @@ export class BillingService {
{ year: number; count: bigint | number | string }[]
>`
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
FROM transactions GROUP BY year ORDER BY year DESC
FROM transactions WHERE voidedAt IS NULL GROUP BY year ORDER BY year DESC
`;
return {
@@ -573,14 +592,18 @@ export class BillingService {
checkNumber: true,
message: true,
legacySourceTable: true,
voidedAt: true,
type: { select: { nameEn: true, nameEs: true } },
},
});
const running = new Map<string, Prisma.Decimal>();
const movements = rows.map((r) => {
const voided = r.voidedAt != null;
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
const next = prev.plus(r.amount);
// A voided row does not move the running balance — it shows struck-through
// with the balance unchanged from the previous live movement.
const next = voided ? prev : prev.plus(r.amount);
running.set(r.currency, next);
return {
id: r.id,
@@ -595,6 +618,7 @@ export class BillingService {
message: r.message,
source: r.legacySourceTable,
type: r.type,
voided,
/** Balance in this row's currency after applying it. */
balanceAfter: next.toFixed(2),
};
@@ -628,6 +652,7 @@ export class BillingService {
>();
for (const r of rows) {
if (r.voidedAt != null) continue; // voided rows never enter a total
const c =
perCurrency.get(r.currency) ??
{
@@ -675,6 +700,7 @@ export class BillingService {
{ name: string; currency: string; total: Prisma.Decimal; count: number }
>();
for (const r of rows) {
if (r.voidedAt != null) continue;
if (!r.amount.lessThan(0)) continue;
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
const key = `${name}|${r.currency}`;
@@ -722,4 +748,45 @@ export class BillingService {
movements,
};
}
// --- writes (append + void; never edit or delete a booked row) ------------
async createMovement(dto: CreateMovementDto) {
const customer = await this.prisma.customer.findUnique({
where: { id: dto.customerId },
select: { id: true },
});
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
const date = new Date(dto.transactionDate);
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
return this.prisma.transaction.create({
data: {
customerId: dto.customerId,
domain: dto.domain,
amount: dto.amount,
transactionDate: date,
currency: dto.currency,
typeId: dto.typeId,
period: dto.period,
reference: dto.reference,
checkNumber: dto.checkNumber,
message: dto.message,
},
});
}
/** Reverse a movement by marking it voided; it stops counting toward totals. */
async voidMovement(id: string, userId: string) {
const tx = await this.prisma.transaction.findUnique({
where: { id },
select: { id: true, voidedAt: true },
});
if (!tx) throw new NotFoundException(`Transaction ${id} not found`);
if (tx.voidedAt) throw new BadRequestException("El movimiento ya está anulado");
return this.prisma.transaction.update({
where: { id },
data: { voidedAt: new Date(), voidedById: userId },
});
}
}
+27
View File
@@ -0,0 +1,27 @@
import {
IsEnum,
IsNumber,
IsOptional,
IsString,
MinLength,
} from "class-validator";
import { Currency, TransactionDomain } from "@jorgecuadros/database";
/**
* A new ledger movement. `amount` is signed: negative = cargo (charge),
* positive = abono (credit) — the module's sign convention. Booked movements
* are never edited; a mistake is corrected by voiding and re-capturing.
*/
export class CreateMovementDto {
@IsString() @MinLength(1) customerId!: string;
@IsEnum(TransactionDomain) domain!: TransactionDomain;
@IsNumber() amount!: number;
@IsString() @MinLength(1) transactionDate!: string;
@IsOptional() @IsEnum(Currency) currency?: Currency;
@IsOptional() @IsString() typeId?: string;
@IsOptional() @IsString() period?: string;
@IsOptional() @IsString() reference?: string;
@IsOptional() @IsString() checkNumber?: string;
@IsOptional() @IsString() message?: string;
}
+34
View File
@@ -0,0 +1,34 @@
import { Injectable } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
/**
* Thin writer over the existing ActivityLog model. Every mutating route calls
* this so who-did-what is recorded — the structural replacement for the old
* PHP app's scattered Logger calls. Best-effort: a logging failure must never
* fail the underlying write, so callers `void audit.log(...)` without awaiting.
*/
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async log(
userId: string | null | undefined,
event: string,
message?: Record<string, unknown>,
level = "info",
): Promise<void> {
try {
await this.prisma.activityLog.create({
data: {
userId: userId ?? undefined,
event,
level,
message: (message as Prisma.InputJsonValue) ?? undefined,
},
});
} catch {
/* never let audit logging break a real write */
}
}
}
+10
View File
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { AuditService } from "./audit.service";
@Global()
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class CommonModule {}
@@ -0,0 +1,42 @@
import {
IsBoolean,
IsEmail,
IsEnum,
IsNumber,
IsOptional,
IsString,
MinLength,
} from "class-validator";
import { Currency } from "@jorgecuadros/database";
/**
* Editable customer fields. Internal/derived columns (nameSource, nameMissing,
* legacy* provenance, archivedAt) are managed by the service, not the client.
* `name` is the only required field; everything else is optional.
*/
export class CreateCustomerDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsString() addressLine1?: string;
@IsOptional() @IsString() addressLine2?: string;
@IsOptional() @IsString() city?: string;
@IsOptional() @IsString() state?: string;
@IsOptional() @IsString() zipCode?: string;
@IsOptional() @IsString() country?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() mobile?: string;
@IsOptional() @IsString() fax?: string;
@IsOptional() @IsEmail() email?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsString() identificationType?: string;
@IsOptional() @IsString() identificationNumber?: string;
/** ISO date string; coerced to Date by the service. */
@IsOptional() @IsString() identificationExpiration?: string;
@IsOptional() @IsString() customerSince?: string;
@IsOptional() @IsBoolean() status?: boolean;
@IsOptional() @IsNumber() minimumBalance?: number;
@IsOptional() @IsNumber() feeAmount?: number;
@IsOptional() @IsEnum(Currency) preferredCurrency?: Currency;
}
+71 -4
View File
@@ -1,11 +1,35 @@
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import { CustomersService } from "./customers.service";
import { CreateCustomerDto } from "./create-customer.dto";
import { UpdateCustomerDto } from "./update-customer.dto";
@UseGuards(AuthenticatedGuard)
@UseGuards(AuthenticatedGuard, AbilityGuard)
@Controller("customers")
export class CustomersController {
constructor(private readonly customers: CustomersService) {}
constructor(
private readonly customers: CustomersService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get("stats")
stats() {
@@ -18,14 +42,57 @@ export class CustomersController {
@Query("page") page?: string,
@Query("pageSize") pageSize?: string,
@Query("line") line?: "utility" | "insurance" | "both",
@Query("includeArchived") includeArchived?: string,
) {
const p = Math.max(1, Number(page) || 1);
const ps = Math.min(100, Math.max(1, Number(pageSize) || 25));
return this.customers.list({ query, page: p, pageSize: ps, line });
return this.customers.list({
query,
page: p,
pageSize: ps,
line,
includeArchived: includeArchived === "true",
});
}
@Get(":id")
detail(@Param("id") id: string) {
return this.customers.detail(id);
}
@Post()
@RequireAbility("customer:create")
async create(@Body() dto: CreateCustomerDto, @Req() req: Request) {
const c = await this.customers.create(dto);
void this.audit.log(this.actingId(req), "customer.create", { customerId: c.id, name: c.name });
return c;
}
@Patch(":id")
@RequireAbility("customer:update")
async update(
@Param("id") id: string,
@Body() dto: UpdateCustomerDto,
@Req() req: Request,
) {
const c = await this.customers.update(id, dto);
void this.audit.log(this.actingId(req), "customer.update", { customerId: id });
return c;
}
@Delete(":id")
@RequireAbility("customer:delete")
async archive(@Param("id") id: string, @Req() req: Request) {
const c = await this.customers.archive(id);
void this.audit.log(this.actingId(req), "customer.archive", { customerId: id });
return c;
}
@Post(":id/restore")
@RequireAbility("customer:delete")
async restore(@Param("id") id: string, @Req() req: Request) {
const c = await this.customers.restore(id);
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
return c;
}
}
+70 -2
View File
@@ -1,12 +1,23 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CreateCustomerDto } from "./create-customer.dto";
import { UpdateCustomerDto } from "./update-customer.dto";
export interface ListParams {
query?: string;
page: number;
pageSize: number;
line?: "utility" | "insurance" | "both";
includeArchived?: boolean;
}
/** Parse an optional ISO date string to a Date (or null to clear it). */
function toDate(v?: string): 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;
}
@Injectable()
@@ -14,9 +25,11 @@ export class CustomersService {
constructor(private readonly prisma: PrismaService) {}
/** Unified customer list with search + business-line filter, paginated. */
async list({ query, page, pageSize, line }: ListParams) {
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
const where: Prisma.CustomerWhereInput = {};
if (!includeArchived) where.archivedAt = null;
if (query && query.trim()) {
const q = query.trim();
where.OR = [
@@ -54,6 +67,7 @@ export class CustomersService {
phone: true,
mobile: true,
status: true,
archivedAt: true,
_count: { select: { properties: true, policies: true, transactions: true } },
},
}),
@@ -69,6 +83,7 @@ export class CustomersService {
phone: r.phone,
mobile: r.mobile,
status: r.status,
archived: r.archivedAt != null,
propertyCount: r._count.properties,
policyCount: r._count.policies,
transactionCount: r._count.transactions,
@@ -117,7 +132,8 @@ export class CustomersService {
// business lines" payoff), computed in the DB rather than in JS.
const summary = await this.prisma.transaction.groupBy({
by: ["domain", "currency"],
where: { customerId: id },
// Exclude voided rows so the per-domain balance matches the statement.
where: { customerId: id, voidedAt: null },
_sum: { amount: true },
_count: { _all: true },
});
@@ -133,6 +149,58 @@ export class CustomersService {
};
}
// --- writes ---------------------------------------------------------------
private toData(dto: CreateCustomerDto | UpdateCustomerDto) {
// Whitelisted by the DTO already; map the date strings to Date objects.
const { identificationExpiration, customerSince, ...rest } = dto;
return {
...rest,
...(identificationExpiration !== undefined && {
identificationExpiration: toDate(identificationExpiration),
}),
...(customerSince !== undefined && { customerSince: toDate(customerSince) }),
};
}
async create(dto: CreateCustomerDto) {
return this.prisma.customer.create({
// App-created rows: nameMissing false (name is required), no legacy
// provenance — those columns stay null, marking a native record.
data: { ...this.toData(dto), name: dto.name, nameMissing: false },
});
}
async update(id: string, dto: UpdateCustomerDto) {
await this.ensureExists(id);
return this.prisma.customer.update({ where: { id }, data: this.toData(dto) });
}
/** Soft-delete: hide from default lists, keep the row + provenance. */
async archive(id: string) {
await this.ensureExists(id);
return this.prisma.customer.update({
where: { id },
data: { archivedAt: new Date() },
});
}
async restore(id: string) {
await this.ensureExists(id);
return this.prisma.customer.update({
where: { id },
data: { archivedAt: null },
});
}
private async ensureExists(id: string) {
const found = await this.prisma.customer.findUnique({
where: { id },
select: { id: true },
});
if (!found) throw new NotFoundException(`Customer ${id} not found`);
}
/** Top-line counts for a dashboard header. */
async stats() {
const [customers, withUtilities, withInsurance, policies, properties, transactions] =
@@ -0,0 +1,34 @@
import {
IsBoolean,
IsEmail,
IsEnum,
IsNumber,
IsOptional,
IsString,
MinLength,
} from "class-validator";
import { Currency } from "@jorgecuadros/database";
/** Same editable fields as create, all optional. */
export class UpdateCustomerDto {
@IsOptional() @IsString() @MinLength(1) name?: string;
@IsOptional() @IsString() addressLine1?: string;
@IsOptional() @IsString() addressLine2?: string;
@IsOptional() @IsString() city?: string;
@IsOptional() @IsString() state?: string;
@IsOptional() @IsString() zipCode?: string;
@IsOptional() @IsString() country?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() mobile?: string;
@IsOptional() @IsString() fax?: string;
@IsOptional() @IsEmail() email?: string;
@IsOptional() @IsString() notes?: string;
@IsOptional() @IsString() identificationType?: string;
@IsOptional() @IsString() identificationNumber?: string;
@IsOptional() @IsString() identificationExpiration?: string;
@IsOptional() @IsString() customerSince?: string;
@IsOptional() @IsBoolean() status?: boolean;
@IsOptional() @IsNumber() minimumBalance?: number;
@IsOptional() @IsNumber() feeAmount?: number;
@IsOptional() @IsEnum(Currency) preferredCurrency?: Currency;
}
+78
View File
@@ -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 {}
+26
View File
@@ -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 {}
+146
View File
@@ -0,0 +1,146 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import { PoliciesService } 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,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get()
list() {
return this.policies.listLookups();
}
@Post("providers")
@RequireAbility("lookup:manage")
async createProvider(@Body() dto: ProviderDto, @Req() req: Request) {
const row = await this.policies.createProvider(dto);
void this.audit.log(this.actingId(req), "lookup.provider.create", {
providerId: row.id,
name: row.name,
});
return row;
}
@Patch("providers/:id")
@RequireAbility("lookup:manage")
async updateProvider(
@Param("id") id: string,
@Body() dto: UpdateProviderDto,
@Req() req: Request,
) {
const row = await this.policies.updateProvider(id, dto);
void this.audit.log(this.actingId(req), "lookup.provider.update", {
providerId: id,
});
return row;
}
@Delete("providers/:id")
@RequireAbility("lookup:manage")
async removeProvider(@Param("id") id: string, @Req() req: Request) {
const row = await this.policies.removeProvider(id);
void this.audit.log(this.actingId(req), "lookup.provider.delete", {
providerId: id,
});
return row;
}
@Post("policy-types")
@RequireAbility("lookup:manage")
async createType(@Body() dto: PolicyTypeDto, @Req() req: Request) {
const row = await this.policies.createPolicyType(dto);
void this.audit.log(this.actingId(req), "lookup.policyType.create", {
policyTypeId: row.id,
name: row.name,
});
return row;
}
@Patch("policy-types/:id")
@RequireAbility("lookup:manage")
async updateType(
@Param("id") id: string,
@Body() dto: UpdatePolicyTypeDto,
@Req() req: Request,
) {
const row = await this.policies.updatePolicyType(id, dto);
void this.audit.log(this.actingId(req), "lookup.policyType.update", {
policyTypeId: id,
});
return row;
}
@Delete("policy-types/:id")
@RequireAbility("lookup:manage")
async removeType(@Param("id") id: string, @Req() req: Request) {
const row = await this.policies.removePolicyType(id);
void this.audit.log(this.actingId(req), "lookup.policyType.delete", {
policyTypeId: id,
});
return row;
}
@Post("adjusters")
@RequireAbility("lookup:manage")
async createAdjuster(@Body() dto: AdjusterDto, @Req() req: Request) {
const row = await this.policies.createAdjuster(dto);
void this.audit.log(this.actingId(req), "lookup.adjuster.create", {
adjusterId: row.id,
});
return row;
}
@Patch("adjusters/:id")
@RequireAbility("lookup:manage")
async updateAdjuster(
@Param("id") id: string,
@Body() dto: UpdateAdjusterDto,
@Req() req: Request,
) {
const row = await this.policies.updateAdjuster(id, dto);
void this.audit.log(this.actingId(req), "lookup.adjuster.update", {
adjusterId: id,
});
return row;
}
@Delete("adjusters/:id")
@RequireAbility("lookup:manage")
async removeAdjuster(@Param("id") id: string, @Req() req: Request) {
const row = await this.policies.removeAdjuster(id);
void this.audit.log(this.actingId(req), "lookup.adjuster.delete", {
adjusterId: id,
});
return row;
}
}
+175 -4
View File
@@ -1,10 +1,37 @@
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import {
PoliciesService,
type PolicySort,
type PolicyStatus,
} from "./policies.service";
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
import {
BeneficiaryDto,
ClaimDto,
DriverDto,
InstallmentDto,
UpdateBeneficiaryDto,
UpdateClaimDto,
UpdateDriverDto,
UpdateInstallmentDto,
VehicleDto,
} from "./children.dto";
const STATUSES: PolicyStatus[] = ["active", "expiring", "expired", "undated"];
const SORTS: PolicySort[] = [
@@ -15,15 +42,21 @@ const SORTS: PolicySort[] = [
"premium_desc",
];
/** Clamped expiry window; 30 days is the default renewal horizon. */
function parseDays(days?: string): number {
return Math.min(365, Math.max(1, Number(days) || 30));
}
@UseGuards(AuthenticatedGuard)
@UseGuards(AuthenticatedGuard, AbilityGuard)
@Controller("policies")
export class PoliciesController {
constructor(private readonly policies: PoliciesService) {}
constructor(
private readonly policies: PoliciesService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get("stats")
stats(@Query("days") days?: string) {
@@ -45,6 +78,7 @@ export class PoliciesController {
@Query("typeId") typeId?: string,
@Query("providerId") providerId?: string,
@Query("liquidated") liquidated?: string,
@Query("includeArchived") includeArchived?: string,
@Query("sort") sort?: string,
) {
return this.policies.list({
@@ -59,6 +93,7 @@ export class PoliciesController {
providerId: providerId || undefined,
liquidated:
liquidated === "true" ? true : liquidated === "false" ? false : undefined,
includeArchived: includeArchived === "true",
sort: SORTS.includes(sort as PolicySort)
? (sort as PolicySort)
: "expiry_desc",
@@ -69,4 +104,140 @@ export class PoliciesController {
detail(@Param("id") id: string, @Query("days") days?: string) {
return this.policies.detail(id, parseDays(days));
}
// --- header writes --------------------------------------------------------
@Post()
@RequireAbility("policy:create")
async create(@Body() dto: CreatePolicyDto, @Req() req: Request) {
const p = await this.policies.create(dto);
void this.audit.log(this.actingId(req), "policy.create", { policyId: p.id });
return p;
}
@Patch(":id")
@RequireAbility("policy:update")
async update(@Param("id") id: string, @Body() dto: UpdatePolicyDto, @Req() req: Request) {
const p = await this.policies.update(id, dto);
void this.audit.log(this.actingId(req), "policy.update", { policyId: id });
return p;
}
@Delete(":id")
@RequireAbility("policy:delete")
async archive(@Param("id") id: string, @Req() req: Request) {
const p = await this.policies.archive(id);
void this.audit.log(this.actingId(req), "policy.archive", { policyId: id });
return p;
}
@Post(":id/restore")
@RequireAbility("policy:delete")
async restore(@Param("id") id: string, @Req() req: Request) {
const p = await this.policies.restore(id);
void this.audit.log(this.actingId(req), "policy.restore", { policyId: id });
return p;
}
// --- children (all editing a policy => policy:update) ---------------------
@Post(":id/installments")
@RequireAbility("policy:update")
addInstallment(@Param("id") id: string, @Body() dto: InstallmentDto) {
return this.policies.addInstallment(id, dto);
}
@Patch(":id/installments/:childId")
@RequireAbility("policy:update")
updateInstallment(
@Param("id") id: string,
@Param("childId") childId: string,
@Body() dto: UpdateInstallmentDto,
) {
return this.policies.updateInstallment(id, childId, dto);
}
@Delete(":id/installments/:childId")
@RequireAbility("policy:update")
removeInstallment(@Param("id") id: string, @Param("childId") childId: string) {
return this.policies.removeInstallment(id, childId);
}
@Post(":id/vehicles")
@RequireAbility("policy:update")
addVehicle(@Param("id") id: string, @Body() dto: VehicleDto) {
return this.policies.addVehicle(id, dto);
}
@Patch(":id/vehicles/:childId")
@RequireAbility("policy:update")
updateVehicle(
@Param("id") id: string,
@Param("childId") childId: string,
@Body() dto: VehicleDto,
) {
return this.policies.updateVehicle(id, childId, dto);
}
@Delete(":id/vehicles/:childId")
@RequireAbility("policy:update")
removeVehicle(@Param("id") id: string, @Param("childId") childId: string) {
return this.policies.removeVehicle(id, childId);
}
@Post(":id/drivers")
@RequireAbility("policy:update")
addDriver(@Param("id") id: string, @Body() dto: DriverDto) {
return this.policies.addDriver(id, dto);
}
@Patch(":id/drivers/:childId")
@RequireAbility("policy:update")
updateDriver(
@Param("id") id: string,
@Param("childId") childId: string,
@Body() dto: UpdateDriverDto,
) {
return this.policies.updateDriver(id, childId, dto);
}
@Delete(":id/drivers/:childId")
@RequireAbility("policy:update")
removeDriver(@Param("id") id: string, @Param("childId") childId: string) {
return this.policies.removeDriver(id, childId);
}
@Post(":id/beneficiaries")
@RequireAbility("policy:update")
addBeneficiary(@Param("id") id: string, @Body() dto: BeneficiaryDto) {
return this.policies.addBeneficiary(id, dto);
}
@Patch(":id/beneficiaries/:childId")
@RequireAbility("policy:update")
updateBeneficiary(
@Param("id") id: string,
@Param("childId") childId: string,
@Body() dto: UpdateBeneficiaryDto,
) {
return this.policies.updateBeneficiary(id, childId, dto);
}
@Delete(":id/beneficiaries/:childId")
@RequireAbility("policy:update")
removeBeneficiary(@Param("id") id: string, @Param("childId") childId: string) {
return this.policies.removeBeneficiary(id, childId);
}
@Post(":id/claims")
@RequireAbility("policy:update")
addClaim(@Param("id") id: string, @Body() dto: ClaimDto) {
return this.policies.addClaim(id, dto);
}
@Patch(":id/claims/:childId")
@RequireAbility("policy:update")
updateClaim(
@Param("id") id: string,
@Param("childId") childId: string,
@Body() dto: UpdateClaimDto,
) {
return this.policies.updateClaim(id, childId, dto);
}
@Delete(":id/claims/:childId")
@RequireAbility("policy:update")
removeClaim(@Param("id") id: string, @Param("childId") childId: string) {
return this.policies.removeClaim(id, childId);
}
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { Module } from "@nestjs/common";
import { PoliciesController } from "./policies.controller";
import { LookupsController } from "./lookups.controller";
import { PoliciesService } from "./policies.service";
@Module({
controllers: [PoliciesController],
controllers: [PoliciesController, LookupsController],
providers: [PoliciesService],
})
export class PoliciesModule {}
+253 -2
View File
@@ -1,6 +1,27 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { toDate } from "../common/coerce";
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
import {
BeneficiaryDto,
ClaimDto,
DriverDto,
InstallmentDto,
UpdateBeneficiaryDto,
UpdateClaimDto,
UpdateDriverDto,
UpdateInstallmentDto,
VehicleDto,
} from "./children.dto";
import {
AdjusterDto,
PolicyTypeDto,
ProviderDto,
UpdateAdjusterDto,
UpdatePolicyTypeDto,
UpdateProviderDto,
} from "./lookup.dto";
/**
* Vigencia buckets, derived from `policyTo` against today. `undated` is a real
@@ -27,6 +48,7 @@ export interface ListParams {
typeId?: string;
providerId?: string;
liquidated?: boolean;
includeArchived?: boolean;
sort: PolicySort;
}
@@ -97,11 +119,13 @@ export class PoliciesService {
/** Policy list with search, vigencia/type/provider filters, paginated. */
async list(params: ListParams) {
const { query, page, pageSize, status, days, typeId, providerId, liquidated, sort } =
params;
const { query, page, pageSize, status, days, typeId, providerId, liquidated,
includeArchived, sort } = params;
const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) };
if (!includeArchived) where.archivedAt = null;
if (query && query.trim()) {
const q = query.trim();
where.OR = [
@@ -134,6 +158,7 @@ export class PoliciesService {
total: true,
currency: true,
liquidated: true,
archivedAt: true,
customer: { select: { id: true, name: true, city: true } },
policyType: { select: { id: true, name: true } },
insuranceProvider: { select: { id: true, name: true } },
@@ -155,6 +180,7 @@ export class PoliciesService {
total: r.total,
currency: r.currency,
liquidated: r.liquidated,
archived: r.archivedAt != null,
customerId: r.customer.id,
customerName: r.customer.name,
customerCity: r.customer.city,
@@ -278,4 +304,229 @@ export class PoliciesService {
daysToExpiry: daysUntil(policy.policyTo, from),
};
}
// --- policy header writes -------------------------------------------------
private headerData(dto: CreatePolicyDto | UpdatePolicyDto) {
const { policyDate, policyFrom, policyTo, liquidationDate, ...rest } =
dto as CreatePolicyDto;
return {
...rest,
...(policyDate !== undefined && { policyDate: toDate(policyDate) }),
...(policyFrom !== undefined && { policyFrom: toDate(policyFrom) }),
...(policyTo !== undefined && { policyTo: toDate(policyTo) }),
...(liquidationDate !== undefined && { liquidationDate: toDate(liquidationDate) }),
};
}
async create(dto: CreatePolicyDto) {
// Validate the customer FK up front for a clean 404 instead of a raw
// Prisma constraint error.
const customer = await this.prisma.customer.findUnique({
where: { id: dto.customerId },
select: { id: true },
});
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
return this.prisma.policy.create({
data: {
...this.headerData(dto),
policyNumber: dto.policyNumber,
customerId: dto.customerId,
},
});
}
async update(id: string, dto: UpdatePolicyDto) {
await this.ensurePolicy(id);
return this.prisma.policy.update({ where: { id }, data: this.headerData(dto) });
}
async archive(id: string) {
await this.ensurePolicy(id);
return this.prisma.policy.update({ where: { id }, data: { archivedAt: new Date() } });
}
async restore(id: string) {
await this.ensurePolicy(id);
return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } });
}
private async ensurePolicy(id: string) {
const found = await this.prisma.policy.findUnique({
where: { id },
select: { id: true },
});
if (!found) throw new NotFoundException(`Policy ${id} not found`);
}
// --- child rows -----------------------------------------------------------
// Each child is created under a policy and edited/removed by its own id,
// scoped to that policy so one policy's id can't touch another's rows.
private async ensureChild(
model: "policyPaymentInstallment" | "vehicle" | "insuredDriver" | "policyBeneficiary" | "claim",
policyId: string,
childId: string,
) {
await this.ensurePolicy(policyId);
// @ts-expect-error dynamic delegate access is safe for these known models
const row = await this.prisma[model].findFirst({
where: { id: childId, policyId },
select: { id: true },
});
if (!row) throw new NotFoundException(`Child ${childId} not found on policy ${policyId}`);
}
async addInstallment(policyId: string, dto: InstallmentDto) {
await this.ensurePolicy(policyId);
return this.prisma.policyPaymentInstallment.create({
data: {
policyId,
sequence: dto.sequence,
amount: dto.amount,
currency: dto.currency,
dueDate: toDate(dto.dueDate) ?? undefined,
paidDate: toDate(dto.paidDate) ?? undefined,
checkNumber: dto.checkNumber,
isCash: dto.isCash,
},
});
}
async updateInstallment(policyId: string, id: string, dto: UpdateInstallmentDto) {
await this.ensureChild("policyPaymentInstallment", policyId, id);
return this.prisma.policyPaymentInstallment.update({
where: { id },
data: {
sequence: dto.sequence,
amount: dto.amount,
currency: dto.currency,
...(dto.dueDate !== undefined && { dueDate: toDate(dto.dueDate) }),
...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }),
checkNumber: dto.checkNumber,
isCash: dto.isCash,
},
});
}
async removeInstallment(policyId: string, id: string) {
await this.ensureChild("policyPaymentInstallment", policyId, id);
return this.prisma.policyPaymentInstallment.delete({ where: { id } });
}
async addVehicle(policyId: string, dto: VehicleDto) {
await this.ensurePolicy(policyId);
return this.prisma.vehicle.create({ data: { policyId, ...dto } });
}
async updateVehicle(policyId: string, id: string, dto: VehicleDto) {
await this.ensureChild("vehicle", policyId, id);
return this.prisma.vehicle.update({ where: { id }, data: { ...dto } });
}
async removeVehicle(policyId: string, id: string) {
await this.ensureChild("vehicle", policyId, id);
return this.prisma.vehicle.delete({ where: { id } });
}
async addDriver(policyId: string, dto: DriverDto) {
await this.ensurePolicy(policyId);
return this.prisma.insuredDriver.create({
data: { policyId, ...dto, birthDate: toDate(dto.birthDate) ?? undefined },
});
}
async updateDriver(policyId: string, id: string, dto: UpdateDriverDto) {
await this.ensureChild("insuredDriver", policyId, id);
return this.prisma.insuredDriver.update({
where: { id },
data: { ...dto, ...(dto.birthDate !== undefined && { birthDate: toDate(dto.birthDate) }) },
});
}
async removeDriver(policyId: string, id: string) {
await this.ensureChild("insuredDriver", policyId, id);
return this.prisma.insuredDriver.delete({ where: { id } });
}
async addBeneficiary(policyId: string, dto: BeneficiaryDto) {
await this.ensurePolicy(policyId);
return this.prisma.policyBeneficiary.create({ data: { policyId, ...dto } });
}
async updateBeneficiary(policyId: string, id: string, dto: UpdateBeneficiaryDto) {
await this.ensureChild("policyBeneficiary", policyId, id);
return this.prisma.policyBeneficiary.update({ where: { id }, data: { ...dto } });
}
async removeBeneficiary(policyId: string, id: string) {
await this.ensureChild("policyBeneficiary", policyId, id);
return this.prisma.policyBeneficiary.delete({ where: { id } });
}
async addClaim(policyId: string, dto: ClaimDto) {
await this.ensurePolicy(policyId);
return this.prisma.claim.create({ data: { policyId, ...this.claimData(dto) } });
}
async updateClaim(policyId: string, id: string, dto: UpdateClaimDto) {
await this.ensureChild("claim", policyId, id);
return this.prisma.claim.update({ where: { id }, data: this.claimData(dto) });
}
async removeClaim(policyId: string, id: string) {
await this.ensureChild("claim", policyId, id);
return this.prisma.claim.delete({ where: { id } });
}
private claimData(dto: ClaimDto) {
const { incidentDate, reportedDate, settlementDate, ...rest } = dto;
return {
...rest,
...(incidentDate !== undefined && { incidentDate: toDate(incidentDate) }),
...(reportedDate !== undefined && { reportedDate: toDate(reportedDate) }),
...(settlementDate !== undefined && { settlementDate: toDate(settlementDate) }),
};
}
// --- lookups (providers / policy types / adjusters) -----------------------
listLookups() {
return this.prisma.$transaction([
this.prisma.insuranceProvider.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, _count: { select: { policies: true } } },
}),
this.prisma.policyType.findMany({
orderBy: { name: "asc" },
select: {
id: true,
name: true,
shortDescription: true,
_count: { select: { policies: true } },
},
}),
this.prisma.adjuster.findMany({ orderBy: { name: "asc" } }),
]).then(([providers, types, adjusters]) => ({ providers, types, adjusters }));
}
createProvider(dto: ProviderDto) {
return this.prisma.insuranceProvider.create({ data: dto });
}
updateProvider(id: string, dto: UpdateProviderDto) {
return this.prisma.insuranceProvider.update({ where: { id }, data: dto });
}
removeProvider(id: string) {
return this.prisma.insuranceProvider.delete({ where: { id } });
}
createPolicyType(dto: PolicyTypeDto) {
return this.prisma.policyType.create({ data: dto });
}
updatePolicyType(id: string, dto: UpdatePolicyTypeDto) {
return this.prisma.policyType.update({ where: { id }, data: dto });
}
removePolicyType(id: string) {
return this.prisma.policyType.delete({ where: { id } });
}
createAdjuster(dto: AdjusterDto) {
return this.prisma.adjuster.create({ data: dto });
}
updateAdjuster(id: string, dto: UpdateAdjusterDto) {
return this.prisma.adjuster.update({ where: { id }, data: dto });
}
removeAdjuster(id: string) {
return this.prisma.adjuster.delete({ where: { id } });
}
}
+62
View File
@@ -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;
}
@@ -1,11 +1,34 @@
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { ServiceKind } from "@jorgecuadros/database";
import { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import {
PropertiesService,
type PropertySort,
type TrustFilter,
} from "./properties.service";
import {
CreatePropertyDto,
ServiceDto,
TrustDto,
UpdatePropertyDto,
UpdateServiceDto,
} from "./property.dto";
const KINDS: ServiceKind[] = [
"WATER",
@@ -35,15 +58,21 @@ const SORTS: PropertySort[] = [
"trust_due_desc",
];
/** Clamped trust-renewal window; 30 days matches the policies module. */
function parseDays(days?: string): number {
return Math.min(365, Math.max(1, Number(days) || 30));
}
@UseGuards(AuthenticatedGuard)
@UseGuards(AuthenticatedGuard, AbilityGuard)
@Controller("properties")
export class PropertiesController {
constructor(private readonly properties: PropertiesService) {}
constructor(
private readonly properties: PropertiesService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get("stats")
stats(@Query("days") days?: string) {
@@ -67,6 +96,7 @@ export class PropertiesController {
@Query("hasServices") hasServices?: string,
@Query("customerId") customerId?: string,
@Query("days") days?: string,
@Query("includeArchived") includeArchived?: string,
@Query("sort") sort?: string,
) {
return this.properties.list({
@@ -85,6 +115,7 @@ export class PropertiesController {
hasServices === "true" ? true : hasServices === "false" ? false : undefined,
customerId: customerId || undefined,
days: parseDays(days),
includeArchived: includeArchived === "true",
sort: SORTS.includes(sort as PropertySort)
? (sort as PropertySort)
: "customer",
@@ -95,4 +126,81 @@ export class PropertiesController {
detail(@Param("id") id: string, @Query("days") days?: string) {
return this.properties.detail(id, parseDays(days));
}
// --- header writes --------------------------------------------------------
@Post()
@RequireAbility("property:create")
async create(@Body() dto: CreatePropertyDto, @Req() req: Request) {
const p = await this.properties.create(dto);
void this.audit.log(this.actingId(req), "property.create", { propertyId: p.id });
return p;
}
@Patch(":id")
@RequireAbility("property:update")
async update(@Param("id") id: string, @Body() dto: UpdatePropertyDto, @Req() req: Request) {
const p = await this.properties.update(id, dto);
void this.audit.log(this.actingId(req), "property.update", { propertyId: id });
return p;
}
@Delete(":id")
@RequireAbility("property:delete")
async archive(@Param("id") id: string, @Req() req: Request) {
const p = await this.properties.archive(id);
void this.audit.log(this.actingId(req), "property.archive", { propertyId: id });
return p;
}
@Post(":id/restore")
@RequireAbility("property:delete")
async restore(@Param("id") id: string, @Req() req: Request) {
const p = await this.properties.restore(id);
void this.audit.log(this.actingId(req), "property.restore", { propertyId: id });
return p;
}
// --- services (property:update) -------------------------------------------
@Post(":id/services")
@RequireAbility("property:update")
addService(@Param("id") id: string, @Body() dto: ServiceDto) {
return this.properties.addService(id, dto);
}
@Patch(":id/services/:childId")
@RequireAbility("property:update")
updateService(
@Param("id") id: string,
@Param("childId") childId: string,
@Body() dto: UpdateServiceDto,
) {
return this.properties.updateService(id, childId, dto);
}
@Delete(":id/services/:childId")
@RequireAbility("property:update")
removeService(@Param("id") id: string, @Param("childId") childId: string) {
return this.properties.removeService(id, childId);
}
// --- trust account (1:1) --------------------------------------------------
@Put(":id/trust")
@RequireAbility("property:update")
upsertTrust(@Param("id") id: string, @Body() dto: TrustDto) {
return this.properties.upsertTrust(id, dto);
}
@Delete(":id/trust")
@RequireAbility("property:update")
removeTrust(@Param("id") id: string) {
return this.properties.removeTrust(id);
}
// --- documents (remove pointer only) --------------------------------------
@Delete(":id/documents/:childId")
@RequireAbility("property:update")
removeDocument(@Param("id") id: string, @Param("childId") childId: string) {
return this.properties.removeDocument(id, childId);
}
}
+113 -1
View File
@@ -1,6 +1,14 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, ServiceKind } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { toDate } from "../common/coerce";
import {
CreatePropertyDto,
ServiceDto,
TrustDto,
UpdatePropertyDto,
UpdateServiceDto,
} from "./property.dto";
/**
* Trust (fideicomiso) renewal buckets, derived from `trustAccount.dueDate2`
@@ -37,6 +45,7 @@ export interface ListParams {
customerId?: string;
/** Window in days for the `expiring` trust bucket. */
days: number;
includeArchived?: boolean;
sort: PropertySort;
}
@@ -134,11 +143,14 @@ export class PropertiesService {
hasServices,
customerId,
days,
includeArchived,
sort,
} = params;
const and: Prisma.PropertyWhereInput[] = [this.trustWhere(trust, days)];
if (!includeArchived) and.push({ archivedAt: null });
// Sorting by trust due date is only meaningful for properties that have a
// trust; MySQL would otherwise float the ~966 trust-less rows (NULL first
// on ASC) above every real due date. Scoping is explicit in the UI label.
@@ -191,6 +203,7 @@ export class PropertiesService {
phone2: true,
phone3: true,
zone: true,
archivedAt: true,
customer: {
select: { id: true, name: true, city: true, state: true },
},
@@ -221,6 +234,7 @@ export class PropertiesService {
addressLine1: r.addressLine1,
addressLine2: r.addressLine2,
zone: r.zone,
archived: r.archivedAt != null,
phones: [r.phone1, r.phone2, r.phone3].filter(Boolean) as string[],
customerId: r.customer.id,
customerName: r.customer.name,
@@ -411,7 +425,7 @@ export class PropertiesService {
});
const ledger = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { customerId: property.customerId, domain: "UTILITY" },
where: { customerId: property.customerId, domain: "UTILITY", voidedAt: null },
_sum: { amount: true },
_count: { _all: true },
});
@@ -443,4 +457,102 @@ export class PropertiesService {
})),
};
}
// --- property header writes -----------------------------------------------
async create(dto: CreatePropertyDto) {
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.property.create({ data: { ...dto } });
}
async update(id: string, dto: UpdatePropertyDto) {
await this.ensureProperty(id);
return this.prisma.property.update({ where: { id }, data: { ...dto } });
}
async archive(id: string) {
await this.ensureProperty(id);
return this.prisma.property.update({ where: { id }, data: { archivedAt: new Date() } });
}
async restore(id: string) {
await this.ensureProperty(id);
return this.prisma.property.update({ where: { id }, data: { archivedAt: null } });
}
private async ensureProperty(id: string) {
const found = await this.prisma.property.findUnique({
where: { id },
select: { id: true },
});
if (!found) throw new NotFoundException(`Property ${id} not found`);
}
private async ensureService(propertyId: string, serviceId: string) {
await this.ensureProperty(propertyId);
const row = await this.prisma.propertyService.findFirst({
where: { id: serviceId, propertyId },
select: { id: true },
});
if (!row) throw new NotFoundException(`Service ${serviceId} not found on property ${propertyId}`);
}
// --- services -------------------------------------------------------------
async addService(propertyId: string, dto: ServiceDto) {
await this.ensureProperty(propertyId);
return this.prisma.propertyService.create({ data: { propertyId, ...dto } });
}
async updateService(propertyId: string, id: string, dto: UpdateServiceDto) {
await this.ensureService(propertyId, id);
return this.prisma.propertyService.update({ where: { id }, data: { ...dto } });
}
async removeService(propertyId: string, id: string) {
await this.ensureService(propertyId, id);
return this.prisma.propertyService.delete({ where: { id } });
}
// --- trust account (1:1 upsert) -------------------------------------------
async upsertTrust(propertyId: string, dto: TrustDto) {
await this.ensureProperty(propertyId);
const data = {
bankName: dto.bankName,
trustNumber: dto.trustNumber,
bankFee: dto.bankFee,
...(dto.dueDate1 !== undefined && { dueDate1: toDate(dto.dueDate1) }),
...(dto.dueDate2 !== undefined && { dueDate2: toDate(dto.dueDate2) }),
};
return this.prisma.trustAccount.upsert({
where: { propertyId },
create: { propertyId, ...data },
update: data,
});
}
async removeTrust(propertyId: string) {
await this.ensureProperty(propertyId);
const existing = await this.prisma.trustAccount.findUnique({
where: { propertyId },
select: { id: true },
});
if (!existing) throw new NotFoundException(`No trust account on property ${propertyId}`);
return this.prisma.trustAccount.delete({ where: { propertyId } });
}
// --- documents ------------------------------------------------------------
// Removing a pointer row only; uploading files needs the object-storage
// client wired into the API (today only the migration writes to MinIO).
async removeDocument(propertyId: string, id: string) {
await this.ensureProperty(propertyId);
const row = await this.prisma.serviceDocument.findFirst({
where: { id, propertyId },
select: { id: true },
});
if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`);
return this.prisma.serviceDocument.delete({ where: { id } });
}
}
+58
View File
@@ -0,0 +1,58 @@
import {
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsString,
MinLength,
} from "class-validator";
import { ServiceKind } from "@jorgecuadros/database";
export class CreatePropertyDto {
@IsString() @MinLength(1) customerId!: string;
@IsOptional() @IsString() policyId?: string;
@IsOptional() @IsString() addressLine1?: string;
@IsOptional() @IsString() addressLine2?: string;
@IsOptional() @IsString() phone1?: string;
@IsOptional() @IsString() phone2?: string;
@IsOptional() @IsString() phone3?: string;
@IsOptional() @IsString() zone?: string;
}
export class UpdatePropertyDto {
@IsOptional() @IsString() policyId?: string;
@IsOptional() @IsString() addressLine1?: string;
@IsOptional() @IsString() addressLine2?: string;
@IsOptional() @IsString() phone1?: string;
@IsOptional() @IsString() phone2?: string;
@IsOptional() @IsString() phone3?: string;
@IsOptional() @IsString() zone?: string;
}
export class ServiceDto {
@IsEnum(ServiceKind) kind!: ServiceKind;
@IsOptional() @IsString() accountNumber?: string;
@IsOptional() @IsString() meterNumber?: string;
@IsOptional() @IsString() route?: string;
@IsOptional() @IsString() dueDay?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() notes?: string;
}
export class UpdateServiceDto {
@IsOptional() @IsEnum(ServiceKind) kind?: ServiceKind;
@IsOptional() @IsString() accountNumber?: string;
@IsOptional() @IsString() meterNumber?: string;
@IsOptional() @IsString() route?: string;
@IsOptional() @IsString() dueDay?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() notes?: string;
}
/** Trust is 1:1 with a property — this both creates and updates it (upsert). */
export class TrustDto {
@IsOptional() @IsString() bankName?: string;
@IsOptional() @IsString() trustNumber?: string;
@IsOptional() @IsNumber() bankFee?: number;
@IsOptional() @IsString() dueDate1?: string;
@IsOptional() @IsString() dueDate2?: string;
}
+21
View File
@@ -0,0 +1,21 @@
import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from "class-validator";
import { UserRole } from "@jorgecuadros/database";
export class CreateUserDto {
@IsString()
@MinLength(1)
name!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
@IsEnum(UserRole)
role!: UserRole;
@IsOptional()
active?: boolean;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsString, MinLength } from "class-validator";
export class ResetPasswordDto {
@IsString()
@MinLength(8)
password!: string;
}
+22
View File
@@ -0,0 +1,22 @@
import { IsBoolean, IsEmail, IsEnum, IsOptional, IsString, MinLength } from "class-validator";
import { UserRole } from "@jorgecuadros/database";
/** Password changes go through the dedicated reset-password route, not here. */
export class UpdateUserDto {
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
@IsOptional()
@IsBoolean()
active?: boolean;
}
+72
View File
@@ -0,0 +1,72 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import { Request } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import { UsersService } from "./users.service";
import { CreateUserDto } from "./create-user.dto";
import { UpdateUserDto } from "./update-user.dto";
import { ResetPasswordDto } from "./reset-password.dto";
/** Every route here is ADMIN-only (ability "user:manage"). */
@UseGuards(AuthenticatedGuard, AbilityGuard)
@RequireAbility("user:manage")
@Controller("users")
export class UsersController {
constructor(
private readonly users: UsersService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string }).id;
}
@Get()
list() {
return this.users.list();
}
@Post()
async create(@Body() dto: CreateUserDto, @Req() req: Request) {
const user = await this.users.create(dto);
void this.audit.log(this.actingId(req), "user.create", {
userId: user.id,
email: user.email,
role: user.role,
});
return user;
}
@Patch(":id")
async update(
@Param("id") id: string,
@Body() dto: UpdateUserDto,
@Req() req: Request,
) {
const user = await this.users.update(id, dto, this.actingId(req));
void this.audit.log(this.actingId(req), "user.update", { userId: id, changes: dto });
return user;
}
@Post(":id/reset-password")
async resetPassword(
@Param("id") id: string,
@Body() dto: ResetPasswordDto,
@Req() req: Request,
) {
const user = await this.users.resetPassword(id, dto.password);
void this.audit.log(this.actingId(req), "user.reset_password", { userId: id });
return user;
}
}
+2
View File
@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { UsersService } from "./users.service";
import { UsersController } from "./users.controller";
@Module({
providers: [UsersService],
controllers: [UsersController],
exports: [UsersService],
})
export class UsersModule {}
+111 -2
View File
@@ -1,11 +1,35 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import * as argon2 from "argon2";
import { Prisma } from "@jorgecuadros/database";
import type { User } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { CreateUserDto } from "./create-user.dto";
import { UpdateUserDto } from "./update-user.dto";
/** Shape returned to the UI — never carries passwordHash. */
const safeSelect = {
id: true,
name: true,
email: true,
role: true,
active: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.UserSelect;
export type SafeUserRow = Prisma.UserGetPayload<{ select: typeof safeSelect }>;
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
// --- used by auth (need the hash / full row) -----------------------------
findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } });
}
@@ -13,4 +37,89 @@ export class UsersService {
findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
// --- admin CRUD (safe rows only) -----------------------------------------
list(): Promise<SafeUserRow[]> {
return this.prisma.user.findMany({
orderBy: [{ active: "desc" }, { name: "asc" }],
select: safeSelect,
});
}
async create(dto: CreateUserDto): Promise<SafeUserRow> {
const passwordHash = await argon2.hash(dto.password);
try {
return await this.prisma.user.create({
data: {
name: dto.name,
email: dto.email,
passwordHash,
role: dto.role,
active: dto.active ?? true,
},
select: safeSelect,
});
} catch (e) {
throw this.mapError(e);
}
}
/**
* `actingUserId` is the admin making the change — used to stop an admin from
* locking themselves out (deactivating or demoting their own account).
*/
async update(
id: string,
dto: UpdateUserDto,
actingUserId: string,
): Promise<SafeUserRow> {
await this.ensureExists(id);
if (id === actingUserId) {
if (dto.active === false) {
throw new BadRequestException("No puede desactivar su propia cuenta");
}
if (dto.role && dto.role !== "ADMIN") {
throw new BadRequestException("No puede quitarse su propio rol de administrador");
}
}
try {
return await this.prisma.user.update({
where: { id },
data: {
name: dto.name,
email: dto.email,
role: dto.role,
active: dto.active,
},
select: safeSelect,
});
} catch (e) {
throw this.mapError(e);
}
}
async resetPassword(id: string, password: string): Promise<SafeUserRow> {
await this.ensureExists(id);
const passwordHash = await argon2.hash(password);
return this.prisma.user.update({
where: { id },
data: { passwordHash },
select: safeSelect,
});
}
private async ensureExists(id: string): Promise<void> {
const found = await this.prisma.user.findUnique({ where: { id }, select: { id: true } });
if (!found) throw new NotFoundException(`Usuario ${id} no encontrado`);
}
private mapError(e: unknown): Error {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
return new ConflictException("Ya existe un usuario con ese correo");
}
return e as Error;
}
}
+282 -6
View File
@@ -3,11 +3,14 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import {
createBankMovement,
getBankFacets,
getBankStats,
getBankSummary,
listBankMovements,
voidBankMovement,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import {
bankDirectionLabel,
bankSourceLabel,
@@ -27,6 +30,7 @@ import type {
BankStats,
BankSummary,
BankTotals,
CreateBankMovementInput,
} from "@/lib/types";
/**
@@ -77,6 +81,8 @@ export default function BancoPage() {
}
function BankBrowser() {
const canCapture = useCan("bank:create");
const canVoid = useCan("bank:void");
const [stats, setStats] = useState<BankStats | null>(null);
const [facets, setFacets] = useState<BankFacets | null>(null);
const [view, setView] = useState<View>("movimientos");
@@ -93,6 +99,7 @@ function BankBrowser() {
const [summaryYear, setSummaryYear] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [captureOpen, setCaptureOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
@@ -237,8 +244,28 @@ function BankBrowser() {
</button>
))}
</div>
{view === "movimientos" && canCapture && (
<button
type="button"
className="btn btn-primary"
onClick={() => setCaptureOpen((v) => !v)}
>
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
</button>
)}
</div>
{view === "movimientos" && captureOpen && (
<BankCaptureForm
onSaved={() => {
setCaptureOpen(false);
runSearch(movements?.page ?? 1);
getBankStats().then(setStats).catch(() => setStats(null));
}}
onCancel={() => setCaptureOpen(false)}
/>
)}
{view === "movimientos" && (
<div className="filter-row">
<label className="filter-field">
@@ -383,11 +410,26 @@ function BankBrowser() {
<th>Beneficiario / concepto</th>
<th>Origen</th>
<th className="num">Monto</th>
{canVoid && (
<th style={{ width: 1, whiteSpace: "nowrap" }}>
Acciones
</th>
)}
</tr>
</thead>
<tbody>
{movements?.items.map((m) => (
<BankRow key={m.id} m={m} />
<BankRow
key={m.id}
m={m}
canVoid={canVoid}
onVoided={() => {
runSearch(movements?.page ?? 1);
getBankStats()
.then(setStats)
.catch(() => setStats(null));
}}
/>
))}
</tbody>
</table>
@@ -524,17 +566,44 @@ function FilteredTotals({ totals }: { totals: BankTotals }) {
);
}
function BankRow({ m }: { m: BankListItem }) {
function BankRow({
m,
canVoid,
onVoided,
}: {
m: BankListItem;
canVoid: boolean;
onVoided: () => void;
}) {
const [busy, setBusy] = useState(false);
async function doVoid() {
if (
!window.confirm(
"¿Anular este movimiento? Quedará tachado y no contará en los totales.",
)
)
return;
setBusy(true);
try {
await voidBankMovement(m.id);
onVoided();
} catch (e) {
window.alert(
(e as Error)?.message ?? "No se pudo anular el movimiento.",
);
setBusy(false);
}
}
return (
<tr>
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(m.transactionDate)}
</td>
<td className="tx-ref">
{m.reference || "—"}
{!m.cleared && (
<div className="tx-concept">Sin operar</div>
)}
{!m.cleared && <div className="tx-concept">Sin operar</div>}
</td>
<td>
{m.concept || <span className="muted">Sin concepto</span>}
@@ -547,6 +616,21 @@ function BankRow({ m }: { m: BankListItem }) {
</span>
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
</td>
{canVoid && (
<td style={{ whiteSpace: "nowrap" }}>
{!m.voided && (
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={doVoid}
disabled={busy}
>
{busy ? "Anulando…" : "Anular"}
</button>
)}
</td>
)}
</tr>
);
}
@@ -687,6 +771,198 @@ function SummaryView({
);
}
/** Inline capture form for a single chequera movement. Single currency (MXN);
* sign convention: positive = ingreso, negative = egreso. Booked rows are
* never edited — fix mistakes with voidBankMovement + a fresh capture. */
function BankCaptureForm({
onSaved,
onCancel,
}: {
onSaved: () => void;
onCancel: () => void;
}) {
const [direction, setDirection] = useState<BankDirection>("expense");
const [amount, setAmount] = useState("");
const [transactionDate, setTransactionDate] = useState(
new Date().toISOString().slice(0, 10),
);
const [concept, setConcept] = useState("");
const [reference, setReference] = useState("");
const [transactionType, setTransactionType] = useState("");
const [cleared, setCleared] = useState(true);
const [transferred, setTransferred] = useState(false);
const [notes, setNotes] = useState("");
const [amountInWords, setAmountInWords] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function s(v: string): string | undefined {
const t = v.trim();
return t === "" ? undefined : t;
}
async function submit(e: React.FormEvent) {
e.preventDefault();
const abs = Number(amount);
if (!Number.isFinite(abs) || abs <= 0) {
setError("El monto debe ser un número mayor a cero.");
return;
}
const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs);
const payload: CreateBankMovementInput = {
amount: signed,
transactionDate,
concept: s(concept),
reference: s(reference),
transactionType: s(transactionType),
cleared,
transferred,
notes: s(notes),
amountInWords: s(amountInWords),
};
setSaving(true);
setError(null);
try {
await createBankMovement(payload);
onSaved();
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar el movimiento.");
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 }}>
Capturar movimiento de chequera
</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">
Tipo <span aria-hidden>*</span>
</span>
<select
className="select"
value={direction}
onChange={(e) => setDirection(e.target.value as BankDirection)}
>
<option value="income">Ingreso</option>
<option value="expense">Egreso</option>
</select>
</label>
<label className="field">
<span className="field-label">
Fecha <span aria-hidden>*</span>
</span>
<input
className="input"
type="date"
required
value={transactionDate}
onChange={(e) => setTransactionDate(e.target.value)}
/>
</label>
<label className="field">
<span className="field-label">
Monto (MXN) <span aria-hidden>*</span>
</span>
<input
className="input"
type="number"
step="0.01"
required
min="0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
/>
</label>
<label className="field">
<span className="field-label">Concepto</span>
<input
className="input"
value={concept}
onChange={(e) => setConcept(e.target.value)}
placeholder="Beneficiario o motivo"
/>
</label>
<label className="field">
<span className="field-label">Referencia / cheque</span>
<input
className="input"
value={reference}
onChange={(e) => setReference(e.target.value)}
/>
</label>
<label className="field">
<span className="field-label">Tipo en origen</span>
<input
className="input"
value={transactionType}
onChange={(e) => setTransactionType(e.target.value)}
placeholder="INGRESO / EGRESO"
/>
</label>
<label className="field">
<span className="field-label">Monto en letras</span>
<input
className="input"
value={amountInWords}
onChange={(e) => setAmountInWords(e.target.value)}
placeholder="Ej. CIENTO CINCUENTA MIL PESOS 00/100"
/>
</label>
<label
className="field"
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<input
type="checkbox"
checked={cleared}
onChange={(e) => setCleared(e.target.checked)}
/>
<span className="field-label" style={{ margin: 0 }}>
Operado por el banco
</span>
</label>
<label
className="field"
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<input
type="checkbox"
checked={transferred}
onChange={(e) => setTransferred(e.target.checked)}
/>
<span className="field-label" style={{ margin: 0 }}>
Transferencia
</span>
</label>
</div>
<label className="field" style={{ marginTop: 16 }}>
<span className="field-label">Notas</span>
<textarea
className="input"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Guardando…" : "Capturar movimiento"}
</button>
<button type="button" className="btn btn-outline" onClick={onCancel}>
Cancelar
</button>
</div>
</form>
);
}
function Pager({
page,
pageCount,
+102
View File
@@ -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>[])}
</>
)}
</>
);
}
@@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { CustomerForm } from "@/components/CustomerForm";
import { useCan } from "@/lib/abilities";
import { getCustomer } from "@/lib/api";
import type { CustomerDetail } from "@/lib/types";
export default function EditarClientePage({
params,
}: {
params: { id: string };
}) {
return (
<AppShell>
<EditarCliente id={params.id} />
</AppShell>
);
}
function EditarCliente({ id }: { id: string }) {
const allowed = useCan("customer:update");
const [customer, setCustomer] = useState<CustomerDetail | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!allowed) return;
getCustomer(id)
.then(setCustomer)
.catch((e) => setError(e?.message ?? "No se pudo cargar el cliente."));
}, [id, allowed]);
return (
<>
<div className="page-head">
<Link href={`/clientes/${id}`} className="back-link">
Cliente
</Link>
<h1 className="page-title">Editar cliente</h1>
</div>
{!allowed ? (
<div className="state-box state-error">
No tiene permisos para editar clientes.
</div>
) : error ? (
<div className="state-box state-error">{error}</div>
) : !customer ? (
<div className="empty-inline">
<span className="spinner" aria-label="Cargando" />
</div>
) : (
<CustomerForm customer={customer} />
)}
</>
);
}
+110 -5
View File
@@ -3,7 +3,8 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getCustomer } from "@/lib/api";
import { archiveCustomer, getCustomer, restoreCustomer } from "@/lib/api";
import { useCan } from "@/lib/abilities";
import {
domainLabel,
formatDate,
@@ -86,12 +87,26 @@ function Detail({ id }: { id: string }) {
return (
<div className="rise">
<div className="detail-actionbar">
<BackLink />
<CustomerActions
customer={data}
onChange={() => getCustomer(id).then(setData).catch(() => {})}
/>
</div>
<Hero data={data} hasUtilities={hasUtilities} hasInsurance={hasInsurance} />
<DatosSection data={data} />
<PropiedadesSection properties={data.properties} />
<PolizasSection policies={data.policies} />
<PropiedadesSection
properties={data.properties}
customerId={data.id}
customerName={data.name}
/>
<PolizasSection
policies={data.policies}
customerId={data.id}
customerName={data.name}
/>
<EstadoCuentaSection
customerId={data.id}
summary={data.transactionSummary}
@@ -110,6 +125,58 @@ function BackLink() {
);
}
/** Edit / archive controls, each gated by the matching ability. */
function CustomerActions({
customer,
onChange,
}: {
customer: CustomerDetail;
onChange: () => void;
}) {
const canEdit = useCan("customer:update");
const canDelete = useCan("customer:delete");
const [busy, setBusy] = useState(false);
const archived = customer.archivedAt != null;
async function toggleArchive() {
const verb = archived ? "restaurar" : "archivar";
if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return;
setBusy(true);
try {
if (archived) await restoreCustomer(customer.id);
else await archiveCustomer(customer.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">Archivado</span>}
{canEdit && (
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
Editar
</Link>
)}
{canDelete && (
<button
type="button"
className="btn btn-ghost"
onClick={toggleArchive}
disabled={busy}
>
{archived ? "Restaurar" : "Archivar"}
</button>
)}
</div>
);
}
/* ------------------------------------------------------------------ Hero */
function Hero({
data,
@@ -274,14 +341,33 @@ function KV({
}
/* ----------------------------------------------- Propiedades y servicios */
function PropiedadesSection({ properties }: { properties: Property[] }) {
function PropiedadesSection({
properties,
customerId,
customerName,
}: {
properties: Property[];
customerId: string;
customerName: string;
}) {
const canCreate = useCan("property:create");
return (
<section className="section">
<div className="detail-actionbar">
<SectionHead
rule="servicios"
title="Propiedades y servicios"
count={properties.length}
/>
{canCreate && (
<Link
href={`/servicios/nuevo?customerId=${customerId}&customerName=${encodeURIComponent(customerName)}`}
className="btn btn-outline"
>
+ Nueva propiedad
</Link>
)}
</div>
<div className="card">
{properties.length === 0 ? (
<div className="empty-inline">
@@ -393,14 +479,33 @@ function PropertyCard({ p }: { p: Property }) {
}
/* ------------------------------------------------------ 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 (
<section className="section">
<div className="detail-actionbar">
<SectionHead
rule="seguros"
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">
{policies.length === 0 ? (
<div className="empty-inline">
+36
View File
@@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { CustomerForm } from "@/components/CustomerForm";
import { useCan } from "@/lib/abilities";
export default function NuevoClientePage() {
return (
<AppShell>
<NuevoCliente />
</AppShell>
);
}
function NuevoCliente() {
const allowed = useCan("customer:create");
return (
<>
<div className="page-head">
<Link href="/clientes" className="back-link">
Clientes
</Link>
<h1 className="page-title">Nuevo cliente</h1>
</div>
{allowed ? (
<CustomerForm />
) : (
<div className="state-box state-error">
No tiene permisos para crear clientes.
</div>
)}
</>
);
}
+12 -1
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getStats, listCustomers } from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatNumber, SIN_NOMBRE } from "@/lib/labels";
import type {
BusinessLine,
@@ -39,6 +40,8 @@ function ClientesBrowser() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const canCreate = useCan("customer:create");
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
@@ -93,7 +96,15 @@ function ClientesBrowser() {
<>
<div className="page-head rise">
<p className="eyebrow">Directorio unificado</p>
<h1 className="page-title">Clientes</h1>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<h1 className="page-title" style={{ margin: 0 }}>Clientes</h1>
<span style={{ flex: 1 }} />
{canCreate && (
<Link href="/clientes/nuevo" className="btn btn-primary">
+ Nuevo cliente
</Link>
)}
</div>
<StatStrip stats={stats} />
</div>
+120 -8
View File
@@ -3,7 +3,13 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getStatement } from "@/lib/api";
import { MovementForm } from "@/components/MovementForm";
import {
getBillingFacets,
getStatement,
voidMovement,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import {
balancePhrase,
balanceTone,
@@ -17,6 +23,7 @@ import {
txTypeLabel,
} from "@/lib/labels";
import type {
BillingFacets,
LedgerCurrency,
Statement,
StatementMovement,
@@ -48,14 +55,18 @@ export default function EstadoCuentaDetailPage({
}
function StatementView({ id }: { id: string }) {
const canCapture = useCan("ledger:create");
const canVoid = useCan("ledger:void");
const [data, setData] = useState<Statement | null>(null);
const [facets, setFacets] = useState<BillingFacets | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [captureOpen, setCaptureOpen] = useState(false);
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
const [domain, setDomain] = useState<TransactionDomain | "">("");
useEffect(() => {
function reload() {
let alive = true;
setLoading(true);
setError(null);
@@ -63,9 +74,10 @@ function StatementView({ id }: { id: string }) {
.then((d) => {
if (!alive) return;
setData(d);
// Default to the currency the customer actually moves the most in.
// Default to the currency the customer actually moves the most in;
// preserve a previously-chosen currency across reloads.
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
setCurrency(busiest?.currency ?? "MXN");
setCurrency((prev) => prev ?? busiest?.currency ?? "MXN");
setLoading(false);
})
.catch((e) => {
@@ -80,6 +92,13 @@ function StatementView({ id }: { id: string }) {
return () => {
alive = false;
};
}
useEffect(() => {
const cleanup = reload();
getBillingFacets().then(setFacets).catch(() => setFacets(null));
return cleanup;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const movements = useMemo(() => {
@@ -178,8 +197,35 @@ function StatementView({ id }: { id: string }) {
title="Movimientos"
count={movements.length}
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
right={
canCapture ? (
<button
type="button"
className="btn btn-primary"
onClick={() => setCaptureOpen((v) => !v)}
>
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
</button>
) : undefined
}
/>
{captureOpen && (
<MovementForm
concepts={facets?.types ?? []}
defaultCurrency={currency ?? "MXN"}
defaultCustomer={{
id: data.customer.id,
name: data.customer.name,
}}
onSaved={() => {
setCaptureOpen(false);
reload();
}}
onCancel={() => setCaptureOpen(false)}
/>
)}
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Moneda</span>
@@ -228,11 +274,21 @@ function StatementView({ id }: { id: string }) {
<th>Referencia</th>
<th className="num">Cargo / Abono</th>
<th className="num">Saldo</th>
{canVoid && (
<th style={{ width: 1, whiteSpace: "nowrap" }}>
Acciones
</th>
)}
</tr>
</thead>
<tbody>
{movements.map((m) => (
<StatementRow key={m.id} m={m} />
<StatementRow
key={m.id}
m={m}
canVoid={canVoid}
onVoided={reload}
/>
))}
</tbody>
</table>
@@ -425,10 +481,39 @@ function ConceptosSection({
);
}
function StatementRow({ m }: { m: StatementMovement }) {
function StatementRow({
m,
canVoid,
onVoided,
}: {
m: StatementMovement;
canVoid: boolean;
onVoided: () => void;
}) {
const concept = m.message || m.period || null;
const [busy, setBusy] = useState(false);
async function doVoid() {
if (
!window.confirm(
"¿Anular este movimiento? Quedará tachado y no contará en los totales.",
)
)
return;
setBusy(true);
try {
await voidMovement(m.id);
onVoided();
} catch (e) {
window.alert(
(e as Error)?.message ?? "No se pudo anular el movimiento.",
);
setBusy(false);
}
}
return (
<tr>
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(m.transactionDate)}
</td>
@@ -455,6 +540,21 @@ function StatementRow({ m }: { m: StatementMovement }) {
{formatMoney(m.balanceAfter, m.currency)}
</span>
</td>
{canVoid && (
<td style={{ whiteSpace: "nowrap" }}>
{!m.voided && (
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={doVoid}
disabled={busy}
>
{busy ? "Anulando…" : "Anular"}
</button>
)}
</td>
)}
</tr>
);
}
@@ -464,14 +564,23 @@ function SectionHead({
title,
count,
countSuffix,
right,
}: {
rule: string;
title: string;
count?: number;
countSuffix?: string;
right?: React.ReactNode;
}) {
return (
<div className="section-head">
<div
className="section-head"
style={
right
? { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }
: undefined
}
>
<span className={`section-rule ${rule}`} aria-hidden />
<h2 className="section-title">{title}</h2>
{count != null && (
@@ -479,6 +588,9 @@ function SectionHead({
{formatNumber(count)} {countSuffix ?? ""}
</span>
)}
{right && (
<div style={{ marginLeft: "auto" }}>{right}</div>
)}
</div>
);
}
+88 -3
View File
@@ -3,12 +3,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { MovementForm } from "@/components/MovementForm";
import {
getBillingFacets,
getBillingStats,
listBalances,
listMovements,
voidMovement,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import {
balancePhrase,
balanceTone,
@@ -95,6 +98,8 @@ export default function EstadoCuentaPage() {
}
function BillingBrowser() {
const canCapture = useCan("ledger:create");
const canVoid = useCan("ledger:void");
const [stats, setStats] = useState<BillingStats | null>(null);
const [facets, setFacets] = useState<BillingFacets | null>(null);
const [view, setView] = useState<View>("saldos");
@@ -119,6 +124,7 @@ function BillingBrowser() {
const [movements, setMovements] = useState<MovementListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [captureOpen, setCaptureOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
@@ -287,8 +293,38 @@ function BillingBrowser() {
</button>
))}
</div>
{view === "movimientos" && canCapture && (
<button
type="button"
className="btn btn-primary"
onClick={() => setCaptureOpen((v) => !v)}
>
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
</button>
)}
</div>
{view === "movimientos" && captureOpen && (
<section className="section">
<div className="section-head">
<span className="section-rule cuenta" aria-hidden />
<h2 className="section-title">Capturar movimiento</h2>
</div>
<MovementForm
concepts={facets?.types ?? []}
defaultCurrency={currency}
onSaved={() => {
setCaptureOpen(false);
runSearch(movements?.page ?? 1);
getBillingStats()
.then(setStats)
.catch(() => setStats(null));
}}
onCancel={() => setCaptureOpen(false)}
/>
</section>
)}
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Moneda</span>
@@ -506,11 +542,22 @@ function BillingBrowser() {
<th>Concepto</th>
<th>Referencia</th>
<th className="num">Monto</th>
{canVoid && <th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>}
</tr>
</thead>
<tbody>
{movements?.items.map((m) => (
<MovementRow key={m.id} m={m} />
<MovementRow
key={m.id}
m={m}
canVoid={canVoid}
onVoided={() => {
runSearch(movements?.page ?? 1);
getBillingStats()
.then(setStats)
.catch(() => setStats(null));
}}
/>
))}
</tbody>
</table>
@@ -739,9 +786,32 @@ function BalanceRow({
);
}
function MovementRow({ m }: { m: MovementListItem }) {
function MovementRow({
m,
canVoid,
onVoided,
}: {
m: MovementListItem;
canVoid: boolean;
onVoided: () => void;
}) {
const [busy, setBusy] = useState(false);
async function doVoid() {
if (!window.confirm("¿Anular este movimiento? Quedará tachado y no contará en los totales."))
return;
setBusy(true);
try {
await voidMovement(m.id);
onVoided();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo anular el movimiento.");
setBusy(false);
}
}
return (
<tr>
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(m.transactionDate)}
</td>
@@ -776,6 +846,21 @@ function MovementRow({ m }: { m: MovementListItem }) {
{m.currency} · {directionLabel(m.direction)}
</div>
</td>
{canVoid && (
<td style={{ whiteSpace: "nowrap" }}>
{!m.voided && (
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={doVoid}
disabled={busy}
>
{busy ? "Anulando…" : "Anular"}
</button>
)}
</td>
)}
</tr>
);
}
+96
View File
@@ -267,12 +267,108 @@ button {
font-size: 13px;
color: rgba(242, 239, 231, 0.7);
}
.appbar-user-name {
display: inline-flex;
flex-direction: column;
line-height: 1.2;
}
.appbar-user-role {
font-size: 11px;
color: rgba(242, 239, 231, 0.45);
}
@media (max-width: 640px) {
.appbar-user .appbar-user-name {
display: none;
}
}
/* ============================================================================
Admin form grid (users, and future CRUD forms)
========================================================================== */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 18px;
flex-wrap: wrap;
}
.row-actions {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
.detail-actionbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.inline-form-note {
font-size: 13px;
color: var(--muted, #6b7280);
margin: 4px 0 14px;
}
.child-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.child-editor {
border-top: 1px solid rgba(0, 0, 0, 0.08);
margin-top: 12px;
padding-top: 14px;
}
.picker {
position: relative;
}
.picker-selected {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 8px;
}
.picker-list {
position: absolute;
z-index: 20;
top: 100%;
left: 0;
right: 0;
margin: 4px 0 0;
padding: 4px;
list-style: none;
background: var(--card-bg, #fff);
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
max-height: 280px;
overflow-y: auto;
}
.picker-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border: 0;
background: transparent;
border-radius: 6px;
cursor: pointer;
font: inherit;
}
.picker-item:hover {
background: rgba(0, 0, 0, 0.05);
}
/* ============================================================================
Buttons
========================================================================== */
@@ -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} />
)}
</>
);
}
+170 -2
View File
@@ -3,7 +3,17 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getPolicy } from "@/lib/api";
import {
addPolicyChild,
archivePolicy,
getLookups,
getPolicy,
removePolicyChild,
restorePolicy,
updatePolicyChild,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
import {
expiryPhrase,
formatDate,
@@ -12,7 +22,7 @@ import {
premiumHeadline,
SIN_NOMBRE,
} from "@/lib/labels";
import type { Installment, PolicyDetail } from "@/lib/types";
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
export default function PolizaDetailPage({
params,
@@ -73,9 +83,14 @@ function Detail({ id }: { id: string }) {
if (!data) return null;
const reload = () => getPolicy(id).then(setData).catch(() => {});
return (
<div className="rise">
<div className="detail-actionbar">
<BackLink />
<PolicyActions data={data} onChange={reload} />
</div>
<Hero data={data} />
<ClienteSection data={data} />
<CondicionesSection data={data} />
@@ -87,10 +102,163 @@ function Detail({ id }: { id: string }) {
{data.claims.length > 0 && <SiniestrosSection data={data} />}
<CoberturasSection data={data} />
<DocumentosSection data={data} />
<ChildrenEditor data={data} onChange={reload} />
</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() {
return (
<Link href="/polizas" className="back-link">
+41
View File
@@ -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>
)}
</>
);
}
+9 -1
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { useCan } from "@/lib/abilities";
import {
EXPIRY_WINDOW_DAYS,
getPolicyFacets,
@@ -54,6 +55,7 @@ export default function PolizasPage() {
}
function PolizasBrowser() {
const canCreate = useCan("policy:create");
const [stats, setStats] = useState<PolicyStats | null>(null);
const [facets, setFacets] = useState<PolicyFacets | null>(null);
@@ -122,7 +124,13 @@ function PolizasBrowser() {
<>
<div className="page-head rise">
<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
stats={stats}
status={status}
@@ -0,0 +1,54 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { PropertyForm } from "@/components/PropertyForm";
import { useCan } from "@/lib/abilities";
import { getProperty } from "@/lib/api";
import type { PropertyDetail } from "@/lib/types";
export default function EditarPropiedadPage({
params,
}: {
params: { id: string };
}) {
return (
<AppShell>
<EditarPropiedad id={params.id} />
</AppShell>
);
}
function EditarPropiedad({ id }: { id: string }) {
const allowed = useCan("property:update");
const [property, setProperty] = useState<PropertyDetail | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!allowed) return;
getProperty(id)
.then(setProperty)
.catch((e) => setError(e?.message ?? "No se pudo cargar la propiedad."));
}, [id, allowed]);
return (
<>
<div className="page-head">
<Link href={`/servicios/${id}`} className="back-link"> Propiedad</Link>
<h1 className="page-title">Editar propiedad</h1>
</div>
{!allowed ? (
<div className="state-box state-error">
No tiene permisos para editar propiedades.
</div>
) : error ? (
<div className="state-box state-error">{error}</div>
) : !property ? (
<div className="empty-inline"><span className="spinner" aria-label="Cargando" /></div>
) : (
<PropertyForm property={property} />
)}
</>
);
}
+256 -2
View File
@@ -3,19 +3,32 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getProperty } from "@/lib/api";
import {
addService,
archiveProperty,
getProperty,
removePropertyDocument,
removeService,
removeTrust,
restoreProperty,
updateService,
upsertTrust,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
import {
expiryPhrase,
formatDate,
formatMoney,
formatNumber,
SERVICE_KIND_LABELS,
serviceKindGlyph,
serviceKindLabel,
serviceNoteLabel,
SIN_NOMBRE,
trustStatusLabel,
} from "@/lib/labels";
import type { PropertyDetail, Service, Transaction } from "@/lib/types";
import type { PropertyDetail, Service, Transaction, TrustInput } from "@/lib/types";
export default function PropiedadDetailPage({
params,
@@ -76,9 +89,14 @@ function Detail({ id }: { id: string }) {
if (!data) return null;
const reload = () => getProperty(id).then(setData).catch(() => {});
return (
<div className="rise">
<div className="detail-actionbar">
<BackLink />
<PropertyActions data={data} onChange={reload} />
</div>
<Hero data={data} />
<ClienteSection data={data} />
<ServiciosSection data={data} />
@@ -86,10 +104,246 @@ function Detail({ id }: { id: string }) {
{data.policy && <PolizaSection data={data} />}
<MovimientosSection data={data} />
<DocumentosSection data={data} />
<PropertyEditor data={data} onChange={reload} />
</div>
);
}
/** Edit / archive controls for the property header. */
function PropertyActions({
data,
onChange,
}: {
data: PropertyDetail;
onChange: () => void;
}) {
const canEdit = useCan("property:update");
const canDelete = useCan("property: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 propiedad?`)) return;
setBusy(true);
try {
if (archived) await restoreProperty(data.id);
else await archiveProperty(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={`/servicios/${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 services + trust + documents — only for users who can edit. */
function PropertyEditor({
data,
onChange,
}: {
data: PropertyDetail;
onChange: () => void;
}) {
const canEdit = useCan("property:update");
if (!canEdit) return null;
const SERVICES: ChildConfig = {
apiKind: "services",
title: "Servicios",
fields: [
{
key: "kind",
label: "Tipo",
type: "select",
options: Object.entries(SERVICE_KIND_LABELS).map(([value, label]) => ({
value,
label,
})),
},
{ key: "accountNumber", label: "Cuenta" },
{ key: "meterNumber", label: "Medidor" },
{ key: "route", label: "Ruta" },
{ key: "dueDay", label: "Día pago" },
{ key: "notes", label: "Notas" },
{ key: "active", label: "Activo", type: "checkbox" },
],
};
return (
<section className="section">
<div className="section-head">
<span className="section-rule cuenta" aria-hidden />
<h2 className="section-title">Administrar propiedad</h2>
</div>
<ChildCollection
config={SERVICES}
rows={data.services as unknown as Record<string, unknown>[]}
canEdit={canEdit}
onAdd={async (p) => { await addService(data.id, p as never); onChange(); }}
onSave={async (sid, p) => { await updateService(data.id, sid, p as never); onChange(); }}
onRemove={async (sid) => { await removeService(data.id, sid); onChange(); }}
/>
<TrustEditor data={data} onChange={onChange} />
{data.documents.length > 0 && (
<div className="card" style={{ padding: 16 }}>
<h3 className="section-title" style={{ marginTop: 0 }}>Documentos</h3>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr><th>Tipo</th><th>Clave</th><th className="num">Acción</th></tr>
</thead>
<tbody>
{data.documents.map((d) => (
<tr key={d.id ?? d.storageKey}>
<td>{d.documentType ?? "—"}</td>
<td className="mono">{d.storageKey ?? "—"}</td>
<td>
<div className="row-actions">
<button
type="button"
className="btn btn-ghost"
onClick={async () => {
if (!d.id) return;
if (!window.confirm("¿Eliminar este documento?")) return;
try {
await removePropertyDocument(data.id, d.id);
onChange();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
}
}}
>
Eliminar
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="inline-form-note">
La carga de nuevos documentos requiere el almacenamiento de archivos
(pendiente); aquí solo se pueden eliminar los existentes.
</p>
</div>
)}
</section>
);
}
/** Trust is 1:1 — a small inline form that upserts or clears it. */
function TrustEditor({
data,
onChange,
}: {
data: PropertyDetail;
onChange: () => void;
}) {
const t = data.trustAccount;
const [bankName, setBankName] = useState(t?.bankName ?? "");
const [trustNumber, setTrustNumber] = useState(t?.trustNumber ?? "");
const [bankFee, setBankFee] = useState(t?.bankFee != null ? String(t.bankFee) : "");
const [dueDate1, setDueDate1] = useState(toDateInput(t?.dueDate1));
const [dueDate2, setDueDate2] = useState(toDateInput(t?.dueDate2));
const [busy, setBusy] = useState(false);
async function save() {
setBusy(true);
const input: TrustInput = {
bankName: bankName.trim() || undefined,
trustNumber: trustNumber.trim() || undefined,
bankFee: bankFee.trim() === "" ? undefined : Number(bankFee),
dueDate1: dueDate1 || undefined,
dueDate2: dueDate2 || undefined,
};
try {
await upsertTrust(data.id, input);
onChange();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo guardar el fideicomiso.");
} finally {
setBusy(false);
}
}
async function clear() {
if (!window.confirm("¿Eliminar el fideicomiso de esta propiedad?")) return;
try {
await removeTrust(data.id);
onChange();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
}
}
return (
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
<h3 className="section-title" style={{ marginTop: 0 }}>Fideicomiso</h3>
<div className="form-grid">
<label className="field">
<span className="field-label">Banco</span>
<input className="input" value={bankName} onChange={(e) => setBankName(e.target.value)} />
</label>
<label className="field">
<span className="field-label">No. fideicomiso</span>
<input className="input" value={trustNumber} onChange={(e) => setTrustNumber(e.target.value)} />
</label>
<label className="field">
<span className="field-label">Cuota banco</span>
<input className="input" type="number" step="0.01" value={bankFee} onChange={(e) => setBankFee(e.target.value)} />
</label>
<label className="field">
<span className="field-label">Vence 1</span>
<input className="input" type="date" value={dueDate1} onChange={(e) => setDueDate1(e.target.value)} />
</label>
<label className="field">
<span className="field-label">Vence 2 (próxima)</span>
<input className="input" type="date" value={dueDate2} onChange={(e) => setDueDate2(e.target.value)} />
</label>
</div>
<div className="form-actions">
<button type="button" className="btn btn-primary" onClick={save} disabled={busy}>
{busy ? "Guardando…" : t ? "Guardar fideicomiso" : "Crear fideicomiso"}
</button>
{t && (
<button type="button" className="btn btn-ghost" onClick={clear}>
Eliminar
</button>
)}
</div>
</div>
);
}
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 BackLink() {
return (
<Link href="/servicios" className="back-link">
+41
View File
@@ -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 { PropertyForm } from "@/components/PropertyForm";
import { useCan } from "@/lib/abilities";
export default function NuevaPropiedadPage() {
return (
<AppShell>
<Suspense fallback={null}>
<NuevaPropiedad />
</Suspense>
</AppShell>
);
}
function NuevaPropiedad() {
const allowed = useCan("property:create");
const params = useSearchParams();
const customerId = params.get("customerId") ?? undefined;
const customerName = params.get("customerName") ?? undefined;
return (
<>
<div className="page-head">
<Link href="/servicios" className="back-link"> Propiedades</Link>
<h1 className="page-title">Nueva propiedad</h1>
</div>
{allowed ? (
<PropertyForm fixedCustomerId={customerId} fixedCustomerName={customerName} />
) : (
<div className="state-box state-error">
No tiene permisos para crear propiedades.
</div>
)}
</>
);
}
+9 -1
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { useCan } from "@/lib/abilities";
import {
EXPIRY_WINDOW_DAYS,
getPropertyFacets,
@@ -81,6 +82,7 @@ export default function ServiciosPage() {
}
function ServiciosBrowser() {
const canCreate = useCan("property:create");
const [stats, setStats] = useState<PropertyStats | null>(null);
const [facets, setFacets] = useState<PropertyFacets | null>(null);
@@ -164,7 +166,13 @@ function ServiciosBrowser() {
<>
<div className="page-head rise">
<p className="eyebrow">Administración de servicios</p>
<h1 className="page-title">Propiedades</h1>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<h1 className="page-title" style={{ margin: 0 }}>Propiedades</h1>
<span style={{ flex: 1 }} />
{canCreate && (
<Link href="/servicios/nuevo" className="btn btn-primary">+ Nueva propiedad</Link>
)}
</div>
<StatStrip stats={stats} focus={focus} onPickFocus={pickFocus} />
<ServiceMixStrip
stats={stats}
+327
View File
@@ -0,0 +1,327 @@
"use client";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { useAuth, useCan } from "@/lib/abilities";
import { ROLE_LABEL, ROLES_DESC } from "@/lib/labels";
import {
createUser,
listUsers,
resetUserPassword,
updateUser,
} from "@/lib/api";
import type { Role, UserRow } from "@/lib/types";
export default function UsuariosPage() {
return (
<AppShell>
<UsuariosAdmin />
</AppShell>
);
}
type FormState = {
name: string;
email: string;
password: string;
role: Role;
active: boolean;
};
const EMPTY_FORM: FormState = {
name: "",
email: "",
password: "",
role: "STAFF",
active: true,
};
function UsuariosAdmin() {
const me = useAuth();
const allowed = useCan("user:manage");
const [users, setUsers] = useState<UserRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// null = create mode; a user id = editing that row.
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
// Inline "reset password" target + value.
const [pwTarget, setPwTarget] = useState<string | null>(null);
const [pwValue, setPwValue] = useState("");
function refresh() {
listUsers()
.then(setUsers)
.catch((e) => setError(e?.message ?? "No se pudieron cargar los usuarios."));
}
useEffect(() => {
if (allowed) refresh();
}, [allowed]);
if (!allowed) {
return (
<div className="page-head">
<h1 className="page-title">Usuarios</h1>
<div className="state-box state-error">
No tiene permisos para administrar usuarios.
</div>
</div>
);
}
function startCreate() {
setEditingId(null);
setForm(EMPTY_FORM);
setNotice(null);
setError(null);
}
function startEdit(u: UserRow) {
setEditingId(u.id);
setForm({ name: u.name, email: u.email, password: "", role: u.role, active: u.active });
setNotice(null);
setError(null);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
setNotice(null);
try {
if (editingId) {
await updateUser(editingId, {
name: form.name,
email: form.email,
role: form.role,
active: form.active,
});
setNotice("Usuario actualizado.");
} else {
await createUser({
name: form.name,
email: form.email,
password: form.password,
role: form.role,
active: form.active,
});
setNotice("Usuario creado.");
}
startCreate();
refresh();
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar el usuario.");
} finally {
setSaving(false);
}
}
async function submitPassword(id: string) {
setError(null);
try {
await resetUserPassword(id, pwValue);
setPwTarget(null);
setPwValue("");
setNotice("Contraseña restablecida.");
} catch (e) {
setError((e as Error)?.message ?? "No se pudo restablecer la contraseña.");
}
}
return (
<>
<div className="page-head">
<h1 className="page-title">Usuarios</h1>
</div>
{error && <div className="state-box state-error">{error}</div>}
{notice && <div className="state-box">{notice}</div>}
{/* Create / edit form */}
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<h2 className="section-title" style={{ marginBottom: 4 }}>
{editingId ? "Editar usuario" : "Nuevo usuario"}
</h2>
<p className="inline-form-note">
El rol define el acceso: Solo lectura no puede escribir; Personal y
superior . Administrador gestiona usuarios.
</p>
<form onSubmit={submit}>
<div className="form-grid">
<label className="field">
<span className="field-label">Nombre</span>
<input
className="input"
required
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</label>
<label className="field">
<span className="field-label">Correo</span>
<input
className="input"
type="email"
required
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</label>
{!editingId && (
<label className="field">
<span className="field-label">Contraseña (mín. 8)</span>
<input
className="input"
type="password"
required
minLength={8}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</label>
)}
<label className="field">
<span className="field-label">Rol</span>
<select
className="select"
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as Role })}
>
{ROLES_DESC.map((r) => (
<option key={r} value={r}>
{ROLE_LABEL[r]}
</option>
))}
</select>
</label>
<label className="field" style={{ justifyContent: "flex-end" }}>
<span className="field-label">Activo</span>
<input
type="checkbox"
checked={form.active}
disabled={editingId === me?.id}
onChange={(e) => setForm({ ...form, active: e.target.checked })}
/>
</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Guardando…" : editingId ? "Guardar cambios" : "Crear usuario"}
</button>
{editingId && (
<button type="button" className="btn btn-outline" onClick={startCreate}>
Cancelar
</button>
)}
</div>
</form>
</div>
{/* List */}
<div className="card">
{users === null ? (
<div className="empty-inline">
<span className="spinner" aria-label="Cargando" />
</div>
) : users.length === 0 ? (
<div className="empty-inline">Sin usuarios.</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Nombre</th>
<th>Correo</th>
<th>Rol</th>
<th>Estado</th>
<th className="num">Acciones</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
{u.name}
{u.id === me?.id && (
<span className="muted"> (usted)</span>
)}
</td>
<td className="mono">{u.email}</td>
<td>
<span className="badge badge-neutral">{ROLE_LABEL[u.role]}</span>
</td>
<td>
<span
className={`badge ${u.active ? "badge-positive" : "badge-negative"}`}
>
{u.active ? "Activo" : "Inactivo"}
</span>
</td>
<td>
{pwTarget === u.id ? (
<div className="row-actions">
<input
className="input"
type="password"
placeholder="Nueva contraseña"
minLength={8}
value={pwValue}
onChange={(e) => setPwValue(e.target.value)}
style={{ maxWidth: 180 }}
/>
<button
className="btn btn-primary"
type="button"
disabled={pwValue.length < 8}
onClick={() => submitPassword(u.id)}
>
Guardar
</button>
<button
className="btn btn-ghost"
type="button"
onClick={() => {
setPwTarget(null);
setPwValue("");
}}
>
Cancelar
</button>
</div>
) : (
<div className="row-actions">
<button
className="btn btn-outline"
type="button"
onClick={() => startEdit(u)}
>
Editar
</button>
<button
className="btn btn-ghost"
type="button"
onClick={() => {
setPwTarget(u.id);
setPwValue("");
}}
>
Contraseña
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
+18 -8
View File
@@ -4,19 +4,24 @@ import { useEffect, useState, type ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import { logout, me } from "@/lib/api";
import type { AuthUser } from "@/lib/types";
import { AuthContext, can } from "@/lib/abilities";
import { ROLE_LABEL } from "@/lib/labels";
import type { AuthUser, Ability } from "@/lib/types";
/**
* Authenticated shell: gates on /auth/me, redirects to /login when the
* session is missing, renders the brand header + logout, and wraps page
* content. Used by every authenticated page.
* content. Provides the AuthContext so any page can read the user's
* abilities. Used by every authenticated page.
*/
const NAV = [
const NAV: { href: string; label: string; ability?: Ability }[] = [
{ href: "/clientes", label: "Clientes" },
{ href: "/servicios", label: "Propiedades" },
{ href: "/polizas", label: "Pólizas" },
{ href: "/estado-cuenta", label: "Estado de cuenta" },
{ href: "/banco", label: "Chequera" },
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
];
export function AppShell({ children }: { children: ReactNode }) {
@@ -69,7 +74,7 @@ export function AppShell({ children }: { children: ReactNode }) {
}
return (
<>
<AuthContext.Provider value={user}>
<header className="appbar">
<div className="appbar-inner">
<Link href="/clientes" className="brand">
@@ -82,7 +87,8 @@ export function AppShell({ children }: { children: ReactNode }) {
</span>
</Link>
<nav className="appbar-nav" aria-label="Principal">
{NAV.map((item) => {
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
(item) => {
const active = pathname?.startsWith(item.href) ?? false;
return (
<Link
@@ -94,12 +100,16 @@ export function AppShell({ children }: { children: ReactNode }) {
{item.label}
</Link>
);
})}
},
)}
</nav>
<span className="appbar-spacer" />
<div className="appbar-user">
{user && (
<span className="appbar-user-name">{user.name}</span>
<span className="appbar-user-name">
{user.name}
<span className="appbar-user-role">{ROLE_LABEL[user.role]}</span>
</span>
)}
<button
type="button"
@@ -113,6 +123,6 @@ export function AppShell({ children }: { children: ReactNode }) {
</div>
</header>
<main className="shell-main">{children}</main>
</>
</AuthContext.Provider>
);
}
+259
View File
@@ -0,0 +1,259 @@
"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.");
}
}
const colCount = config.fields.length + (canEdit ? 1 : 0);
function editorRow() {
return (
<tr>
<td colSpan={colCount}>{editor()}</td>
</tr>
);
}
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>
{adding && editorRow()}
{rows.map((row) =>
editingId === String(row.id) ? (
<tr key={String(row.id)}>
<td colSpan={colCount}>{editor()}</td>
</tr>
) : (
<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>
)}
</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);
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { CustomerDetail, CustomerInput, Currency } from "@/lib/types";
import { createCustomer, updateCustomer } from "@/lib/api";
/** ISO date (yyyy-mm-dd) for a date input, from an API date string. */
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 | null | undefined): number | undefined {
if (v === null || v === undefined || v === "") return undefined;
const n = Number(v);
return isNaN(n) ? undefined : n;
}
type Values = {
name: string;
addressLine1: string;
addressLine2: string;
city: string;
state: string;
zipCode: string;
country: string;
phone: string;
mobile: string;
fax: string;
email: string;
identificationType: string;
identificationNumber: string;
identificationExpiration: string;
customerSince: string;
preferredCurrency: Currency;
minimumBalance: string;
feeAmount: string;
status: boolean;
notes: string;
};
function initial(c?: CustomerDetail): Values {
return {
name: c?.name ?? "",
addressLine1: c?.addressLine1 ?? "",
addressLine2: c?.addressLine2 ?? "",
city: c?.city ?? "",
state: c?.state ?? "",
zipCode: c?.zipCode ?? "",
country: c?.country ?? "",
phone: c?.phone ?? "",
mobile: c?.mobile ?? "",
fax: c?.fax ?? "",
email: c?.email ?? "",
identificationType: c?.identificationType ?? "",
identificationNumber: c?.identificationNumber ?? "",
identificationExpiration: toDateInput(c?.identificationExpiration),
customerSince: toDateInput(c?.customerSince),
preferredCurrency: (c?.preferredCurrency as Currency) ?? "USD",
minimumBalance: c?.minimumBalance != null ? String(c.minimumBalance) : "",
feeAmount: c?.feeAmount != null ? String(c.feeAmount) : "",
status: c?.status ?? true,
notes: c?.notes ?? "",
};
}
/** Empty string -> undefined so we don't send blanks as real values. */
function s(v: string): string | undefined {
const t = v.trim();
return t === "" ? undefined : t;
}
/**
* Shared create/edit form. When `customer` is given it edits (PATCH), otherwise
* it creates (POST). Redirects to the customer's detail page on success.
*/
export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
const router = useRouter();
const editing = !!customer;
const [v, setV] = useState<Values>(() => initial(customer));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function set<K extends keyof Values>(key: K, val: Values[K]) {
setV((prev) => ({ ...prev, [key]: val }));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
const payload: CustomerInput = {
name: v.name.trim(),
addressLine1: s(v.addressLine1),
addressLine2: s(v.addressLine2),
city: s(v.city),
state: s(v.state),
zipCode: s(v.zipCode),
country: s(v.country),
phone: s(v.phone),
mobile: s(v.mobile),
fax: s(v.fax),
email: s(v.email),
identificationType: s(v.identificationType),
identificationNumber: s(v.identificationNumber),
identificationExpiration: s(v.identificationExpiration),
customerSince: s(v.customerSince),
preferredCurrency: v.preferredCurrency,
minimumBalance: numOrUndef(v.minimumBalance),
feeAmount: numOrUndef(v.feeAmount),
status: v.status,
notes: s(v.notes),
};
try {
const saved = editing
? await updateCustomer(customer!.id, payload)
: await createCustomer(payload);
router.push(`/clientes/${saved.id}`);
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar el cliente.");
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 }}>Identidad</h2>
<div className="form-grid">
<Field label="Nombre" required>
<input className="input" required value={v.name}
onChange={(e) => set("name", e.target.value)} />
</Field>
<Field label="Correo">
<input className="input" type="email" value={v.email}
onChange={(e) => set("email", e.target.value)} />
</Field>
<Field label="Teléfono">
<input className="input" value={v.phone}
onChange={(e) => set("phone", e.target.value)} />
</Field>
<Field label="Celular">
<input className="input" value={v.mobile}
onChange={(e) => set("mobile", e.target.value)} />
</Field>
<Field label="Fax">
<input className="input" value={v.fax}
onChange={(e) => set("fax", e.target.value)} />
</Field>
<Field label="Cliente desde">
<input className="input" type="date" value={v.customerSince}
onChange={(e) => set("customerSince", e.target.value)} />
</Field>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>Domicilio</h2>
<div className="form-grid">
<Field label="Dirección 1">
<input className="input" value={v.addressLine1}
onChange={(e) => set("addressLine1", e.target.value)} />
</Field>
<Field label="Dirección 2">
<input className="input" value={v.addressLine2}
onChange={(e) => set("addressLine2", e.target.value)} />
</Field>
<Field label="Ciudad">
<input className="input" value={v.city}
onChange={(e) => set("city", e.target.value)} />
</Field>
<Field label="Estado">
<input className="input" value={v.state}
onChange={(e) => set("state", e.target.value)} />
</Field>
<Field label="Código postal">
<input className="input" value={v.zipCode}
onChange={(e) => set("zipCode", e.target.value)} />
</Field>
<Field label="País">
<input className="input" value={v.country}
onChange={(e) => set("country", e.target.value)} />
</Field>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
Identificación y cuenta
</h2>
<div className="form-grid">
<Field label="Tipo de identificación">
<input className="input" value={v.identificationType}
onChange={(e) => set("identificationType", e.target.value)} />
</Field>
<Field label="Número de identificación">
<input className="input" value={v.identificationNumber}
onChange={(e) => set("identificationNumber", e.target.value)} />
</Field>
<Field label="Vence identificación">
<input className="input" type="date" value={v.identificationExpiration}
onChange={(e) => set("identificationExpiration", e.target.value)} />
</Field>
<Field label="Moneda preferida">
<select className="select" value={v.preferredCurrency}
onChange={(e) => set("preferredCurrency", e.target.value as Currency)}>
<option value="USD">USD</option>
<option value="MXN">MXN</option>
</select>
</Field>
<Field label="Saldo mínimo">
<input className="input" type="number" step="0.01" value={v.minimumBalance}
onChange={(e) => set("minimumBalance", e.target.value)} />
</Field>
<Field label="Cuota">
<input className="input" type="number" step="0.01" value={v.feeAmount}
onChange={(e) => set("feeAmount", e.target.value)} />
</Field>
<Field label="Activo">
<input type="checkbox" checked={v.status}
onChange={(e) => set("status", e.target.checked)} />
</Field>
</div>
<label className="field" style={{ marginTop: 16 }}>
<span className="field-label">Notas</span>
<textarea className="input" rows={3} 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 cliente"}
</button>
<button type="button" className="btn btn-outline" onClick={() => router.back()}>
Cancelar
</button>
</div>
</form>
);
}
function Field({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<label className="field">
<span className="field-label">
{label}
{required && <span aria-hidden> *</span>}
</span>
{children}
</label>
);
}
@@ -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>
);
}
+260
View File
@@ -0,0 +1,260 @@
"use client";
import { useState } from "react";
import { CustomerPicker } from "@/components/CustomerPicker";
import { createMovement } from "@/lib/api";
import type {
CreateMovementInput,
Currency,
Facet,
LedgerCurrency,
TransactionDomain,
} from "@/lib/types";
const DOMAINS: { key: TransactionDomain; label: string }[] = [
{ key: "UTILITY", label: "Servicios" },
{ key: "INSURANCE", label: "Seguros" },
{ key: "TRUST", label: "Fideicomiso" },
];
type Direction = "charge" | "credit";
function today(): string {
return new Date().toISOString().slice(0, 10);
}
function s(v: string): string | undefined {
const t = v.trim();
return t === "" ? undefined : t;
}
/** Capture form for one ledger movement. `defaultCustomer` pre-fills the picker
* when opening from a customer's statement. `concepts` is the list of
* transaction-type facets from the billing module. */
export function MovementForm({
concepts,
defaultCurrency,
defaultCustomer,
defaultDomain,
onSaved,
onCancel,
}: {
concepts: Facet[];
defaultCurrency?: LedgerCurrency;
defaultCustomer?: { id: string; name: string };
defaultDomain?: TransactionDomain;
onSaved: () => void;
onCancel: () => void;
}) {
const [customerId, setCustomerId] = useState(defaultCustomer?.id ?? "");
const [customerName, setCustomerName] = useState(defaultCustomer?.name ?? "");
const [domain, setDomain] = useState<TransactionDomain>(
defaultDomain ?? "UTILITY",
);
const [direction, setDirection] = useState<Direction>("charge");
const [amount, setAmount] = useState("");
const [transactionDate, setTransactionDate] = useState(today());
const [currency, setCurrency] = useState<LedgerCurrency>(
defaultCurrency ?? "MXN",
);
const [typeId, setTypeId] = useState("");
const [period, setPeriod] = useState("");
const [reference, setReference] = useState("");
const [checkNumber, setCheckNumber] = useState("");
const [message, setMessage] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!customerId) {
setError("Selecciona un cliente.");
return;
}
const abs = Number(amount);
if (!Number.isFinite(abs) || abs === 0) {
setError("El monto debe ser un número distinto de cero.");
return;
}
const signed = direction === "charge" ? -Math.abs(abs) : Math.abs(abs);
const payload: CreateMovementInput = {
customerId,
domain,
amount: signed,
transactionDate,
currency: currency as Currency,
typeId: s(typeId),
period: s(period),
reference: s(reference),
checkNumber: s(checkNumber),
message: s(message),
};
setSaving(true);
setError(null);
try {
await createMovement(payload);
onSaved();
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar el movimiento.");
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 }}>Cliente</h2>
<CustomerPicker
value={customerId}
valueName={customerId ? customerName : undefined}
onPick={(id, name) => {
setCustomerId(id);
setCustomerName(name);
}}
/>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>Movimiento</h2>
<div className="form-grid">
<Field label="Línea de negocio" required>
<select
className="select"
value={domain}
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
>
{DOMAINS.map((d) => (
<option key={d.key} value={d.key}>
{d.label}
</option>
))}
</select>
</Field>
<Field label="Fecha" required>
<input
className="input"
type="date"
required
value={transactionDate}
onChange={(e) => setTransactionDate(e.target.value)}
/>
</Field>
<Field label="Tipo" required>
<select
className="select"
value={direction}
onChange={(e) => setDirection(e.target.value as Direction)}
>
<option value="charge">Cargo</option>
<option value="credit">Abono</option>
</select>
</Field>
<Field label="Monto" required>
<input
className="input"
type="number"
step="0.01"
required
min="0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
/>
</Field>
<Field label="Moneda" required>
<select
className="select"
value={currency}
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
>
<option value="MXN">Pesos (MXN)</option>
<option value="USD">Dólares (USD)</option>
</select>
</Field>
<Field label="Concepto">
<select
className="select"
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
>
<option value="">(sin concepto)</option>
{concepts.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</Field>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>Detalles</h2>
<div className="form-grid">
<Field label="Periodo">
<input
className="input"
value={period}
onChange={(e) => setPeriod(e.target.value)}
placeholder="Ej. 2025-01"
/>
</Field>
<Field label="Referencia">
<input
className="input"
value={reference}
onChange={(e) => setReference(e.target.value)}
/>
</Field>
<Field label="Número de cheque">
<input
className="input"
value={checkNumber}
onChange={(e) => setCheckNumber(e.target.value)}
/>
</Field>
</div>
<label className="field" style={{ marginTop: 16 }}>
<span className="field-label">Mensaje</span>
<textarea
className="input"
rows={2}
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Guardando…" : "Capturar movimiento"}
</button>
<button type="button" className="btn btn-outline" onClick={onCancel}>
Cancelar
</button>
</div>
</form>
);
}
function Field({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<label className="field">
<span className="field-label">
{label}
{required && <span aria-hidden> *</span>}
</span>
{children}
</label>
);
}
+292
View File
@@ -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>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { CustomerPicker } from "@/components/CustomerPicker";
import { createProperty, updateProperty } from "@/lib/api";
import type { PropertyDetail, PropertyInput } from "@/lib/types";
function s(v: string): string | undefined {
const t = v.trim();
return t === "" ? undefined : t;
}
type V = {
addressLine1: string;
addressLine2: string;
phone1: string;
phone2: string;
phone3: string;
zone: string;
};
function initial(p?: PropertyDetail): V {
return {
addressLine1: p?.addressLine1 ?? "",
addressLine2: p?.addressLine2 ?? "",
phone1: p?.phone1 ?? "",
phone2: p?.phone2 ?? "",
phone3: p?.phone3 ?? "",
zone: p?.zone ?? "",
};
}
export function PropertyForm({
property,
fixedCustomerId,
fixedCustomerName,
}: {
property?: PropertyDetail;
fixedCustomerId?: string;
fixedCustomerName?: string;
}) {
const router = useRouter();
const editing = !!property;
const [v, setV] = useState<V>(() => initial(property));
const [customerId, setCustomerId] = useState(
property?.customer.id ?? fixedCustomerId ?? "",
);
const [customerName, setCustomerName] = useState(
property?.customer.name ?? fixedCustomerName ?? "",
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 propietario.");
return;
}
setSaving(true);
setError(null);
const base = {
addressLine1: s(v.addressLine1),
addressLine2: s(v.addressLine2),
phone1: s(v.phone1),
phone2: s(v.phone2),
phone3: s(v.phone3),
zone: s(v.zone),
};
try {
const saved = editing
? await updateProperty(property!.id, base)
: await createProperty({ ...base, customerId } as PropertyInput);
router.push(`/servicios/${saved.id}`);
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar la propiedad.");
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 }}>Propiedad</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">Propietario *</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">Dirección 1</span>
<input className="input" value={v.addressLine1}
onChange={(e) => set("addressLine1", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Dirección 2</span>
<input className="input" value={v.addressLine2}
onChange={(e) => set("addressLine2", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Zona</span>
<input className="input" value={v.zone}
onChange={(e) => set("zone", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Teléfono 1</span>
<input className="input" value={v.phone1}
onChange={(e) => set("phone1", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Teléfono 2</span>
<input className="input" value={v.phone2}
onChange={(e) => set("phone2", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Teléfono 3</span>
<input className="input" value={v.phone3}
onChange={(e) => set("phone3", e.target.value)} />
</label>
</div>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Guardando…" : editing ? "Guardar cambios" : "Crear propiedad"}
</button>
<button type="button" className="btn btn-outline" onClick={() => router.back()}>
Cancelar
</button>
</div>
</form>
);
}
+24
View File
@@ -0,0 +1,24 @@
// UI-side permission helpers. The rules themselves live server-side
// (apps/api/src/auth/abilities.ts) and arrive resolved on `user.abilities` via
// /auth/me — this module just reads that map. Gating here is cosmetic (show or
// hide a control); the API enforces every write regardless.
import { createContext, useContext } from "react";
import type { Ability, AuthUser } from "./types";
export const AuthContext = createContext<AuthUser | null>(null);
/** The signed-in user (or null while loading). */
export function useAuth(): AuthUser | null {
return useContext(AuthContext);
}
/** Whether the current user may perform `ability`. False when not loaded. */
export function useCan(ability: Ability): boolean {
const user = useAuth();
return user?.abilities?.[ability] ?? false;
}
export function can(user: AuthUser | null, ability: Ability): boolean {
return user?.abilities?.[ability] ?? false;
}
+252
View File
@@ -16,7 +16,10 @@ import type {
BillingFacets,
BillingStats,
BusinessLine,
CreateBankMovementInput,
CreateMovementInput,
CustomerDetail,
CustomerInput,
CustomerListResponse,
CustomerStats,
LedgerCurrency,
@@ -25,19 +28,27 @@ import type {
MovementSort,
PolicyDetail,
PolicyFacets,
PolicyInput,
PolicyListResponse,
PolicySort,
PolicyStats,
PolicyStatus,
LookupsResponse,
PropertyDetail,
PropertyFacets,
PropertyInput,
PropertyListResponse,
PropertySort,
PropertyStats,
ServiceInput,
TrustInput,
Role,
ServiceKind,
Statement,
Transaction,
TransactionDomain,
TrustFilter,
UserRow,
} from "./types";
export const API_ORIGIN =
@@ -130,6 +141,31 @@ export function getCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`);
}
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>("/customers", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateCustomer(
id: string,
input: Partial<CustomerInput>,
): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function archiveCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}`, { method: "DELETE" });
}
export function restoreCustomer(id: string): Promise<CustomerDetail> {
return apiFetch<CustomerDetail>(`/customers/${id}/restore`, { method: "POST" });
}
/* ------------------------------------------------------ Policies module */
/** Renewal horizon in days, shared by the list, stats and detail calls so the
@@ -180,6 +216,83 @@ export function getPolicy(
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 */
export interface PropertyQuery {
@@ -231,6 +344,71 @@ export function getProperty(
return apiFetch<PropertyDetail>(`/properties/${id}?days=${days}`);
}
export function createProperty(input: PropertyInput): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>("/properties", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateProperty(
id: string,
input: Partial<PropertyInput>,
): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function archiveProperty(id: string): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}`, { method: "DELETE" });
}
export function restoreProperty(id: string): Promise<PropertyDetail> {
return apiFetch<PropertyDetail>(`/properties/${id}/restore`, { method: "POST" });
}
// Service child CRUD.
export function addService(propertyId: string, input: ServiceInput): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services`, {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateService(
propertyId: string,
serviceId: string,
input: Partial<ServiceInput>,
): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function removeService(propertyId: string, serviceId: string): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/services/${serviceId}`, {
method: "DELETE",
});
}
// Trust account (1:1 upsert).
export function upsertTrust(propertyId: string, input: TrustInput): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/trust`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export function removeTrust(propertyId: string): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/trust`, { method: "DELETE" });
}
export function removePropertyDocument(
propertyId: string,
documentId: string,
): Promise<unknown> {
return apiFetch(`/properties/${propertyId}/documents/${documentId}`, {
method: "DELETE",
});
}
/* ------------------------------------------- Billing / statements module */
export interface MovementQuery {
@@ -302,6 +480,22 @@ export function getStatement(customerId: string): Promise<Statement> {
return apiFetch<Statement>(`/billing/customers/${customerId}`);
}
/** Append a new ledger movement. Booked movements are never edited — fix
* mistakes with voidMovement + a fresh capture. */
export function createMovement(
input: CreateMovementInput,
): Promise<Transaction> {
return apiFetch<Transaction>("/billing", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Reverse a movement by marking it voided; totals drop it. 400 if already void. */
export function voidMovement(id: string): Promise<Transaction> {
return apiFetch<Transaction>(`/billing/${id}/void`, { method: "POST" });
}
/* ------------------------------------------------- Bank register (chequera) */
export interface BankQuery {
@@ -341,3 +535,61 @@ export function getBankFacets(): Promise<BankFacets> {
export function getBankSummary(year?: number): Promise<BankSummary> {
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
}
/** Append a new chequera movement. Booked rows are never edited — fix mistakes
* with voidBankMovement + a fresh capture. */
export function createBankMovement(
input: CreateBankMovementInput,
): Promise<unknown> {
return apiFetch("/bank", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Reverse a chequera movement by marking it voided; totals drop it. */
export function voidBankMovement(id: string): Promise<unknown> {
return apiFetch(`/bank/${id}/void`, { method: "POST" });
}
/* ------------------------------------------------- Users / administration */
export function listUsers(): Promise<UserRow[]> {
return apiFetch<UserRow[]>("/users");
}
export interface CreateUserInput {
name: string;
email: string;
password: string;
role: Role;
active?: boolean;
}
export function createUser(input: CreateUserInput): Promise<UserRow> {
return apiFetch<UserRow>("/users", {
method: "POST",
body: JSON.stringify(input),
});
}
export interface UpdateUserInput {
name?: string;
email?: string;
role?: Role;
active?: boolean;
}
export function updateUser(id: string, input: UpdateUserInput): Promise<UserRow> {
return apiFetch<UserRow>(`/users/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function resetUserPassword(id: string, password: string): Promise<UserRow> {
return apiFetch<UserRow>(`/users/${id}/reset-password`, {
method: "POST",
body: JSON.stringify({ password }),
});
}
+12
View File
@@ -4,11 +4,23 @@ import type {
BankDirection,
LedgerDirection,
PolicyStatus,
Role,
ServiceKind,
TransactionDomain,
TrustStatus,
} from "./types";
/** Access tiers, high → low. VIEWER is read-only; STAFF+ can write. */
export const ROLE_LABEL: Record<Role, string> = {
ADMIN: "Administrador",
MANAGER: "Gerente",
STAFF: "Personal",
VIEWER: "Solo lectura",
};
/** Roles in descending rank — for populating role <select>s. */
export const ROLES_DESC: Role[] = ["ADMIN", "MANAGER", "STAFF", "VIEWER"];
/**
* Placeholder the migration writes when a legacy record had no name and none
* could be recovered from a secondary table (migration/transform_customers.py).
+237 -2
View File
@@ -1,12 +1,46 @@
// TypeScript types for the Jorge Cuadros & Asociados API responses.
// Decimals arrive as strings, dates as ISO strings.
export type Currency = "USD" | "MXN";
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
export type Ability =
| "customer:create"
| "customer:update"
| "customer:delete"
| "policy:create"
| "policy:update"
| "policy:delete"
| "property:create"
| "property:update"
| "property:delete"
| "ledger:create"
| "ledger:void"
| "bank:create"
| "bank:void"
| "lookup:manage"
| "user:manage";
export interface AuthUser {
id: string;
name: string;
email: string;
role: string;
role: Role;
active: boolean;
// Resolved server-side from role (abilitiesFor in the API); the UI only ever
// reads this map, never re-derives the rules. Server still enforces.
abilities: Record<Ability, boolean>;
}
export interface UserRow {
id: string;
name: string;
email: string;
role: Role;
active: boolean;
createdAt: string;
updatedAt: string;
}
export interface CustomerStats {
@@ -33,6 +67,7 @@ export interface CustomerListItem {
phone: string | null;
mobile: string | null;
status: boolean;
archived: boolean;
propertyCount: number;
policyCount: number;
transactionCount: number;
@@ -120,17 +155,23 @@ export interface Vehicle {
id: string;
make: string | null;
model: string | null;
modelYear: number | null;
modelYear: number | string | null;
licensePlate: string | null;
bodyType: string | null;
engineNumber?: string | null;
vinNumber?: string | null;
stateCode?: string | null;
notes?: string | null;
}
export interface InsuredDriver {
id: string;
fullName: string | null;
licenseNumber: string | null;
birthDate?: string | null;
sex?: string | null;
occupation?: string | null;
licenseState?: string | null;
}
export interface Beneficiary {
@@ -202,6 +243,106 @@ export interface PolicyListItem {
vehicleCount: number;
installmentCount: 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 {
@@ -260,6 +401,10 @@ export interface Claim {
settlementDate: string | null;
status?: string | null;
adjuster: Adjuster | null;
adjusterId?: string | null;
checkNumber?: string | null;
resolved?: boolean;
resolutionNotes?: string | null;
}
export interface PolicyCustomerRef {
@@ -298,6 +443,7 @@ export interface PolicyDetail {
legacySourceDb: string | null;
legacySourceTable: string | null;
legacyId: string | null;
archivedAt: string | null;
status: PolicyStatus;
daysToExpiry: number | null;
customer: PolicyCustomerRef;
@@ -364,6 +510,35 @@ export interface PropertyListItem {
activeServiceCount: number;
documentCount: number;
trust: TrustSummary | null;
archived: boolean;
}
/** Editable property-header fields — shared by the form and the API. */
export interface PropertyInput {
customerId: string;
policyId?: string;
addressLine1?: string;
addressLine2?: string;
phone1?: string;
phone2?: string;
phone3?: string;
zone?: string;
}
export interface ServiceInput {
kind: ServiceKind;
accountNumber?: string;
meterNumber?: string;
route?: string;
dueDay?: string;
active?: boolean;
notes?: string;
}
export interface TrustInput {
bankName?: string;
trustNumber?: string;
bankFee?: number;
dueDate1?: string;
dueDate2?: string;
}
export interface PropertyListResponse {
@@ -415,6 +590,7 @@ export interface PropertyDetail {
phone2: string | null;
phone3: string | null;
zone: string | null;
archivedAt: string | null;
legacySourceTable: string | null;
legacyId: string | null;
customer: PropertyOwnerRef;
@@ -507,6 +683,23 @@ export interface Movement {
/** Legacy table the row came from — `datos2`, `EFECTIVO`, `fee15`, … */
source: string | null;
type: TransactionType | null;
/** App-voided (`voidedAt` set). UI strikes; totals exclude. */
voided: boolean;
}
/** Payload for POST /billing — a new ledger movement. Sign convention: negative
* = cargo (charge), positive = abono (credit). */
export interface CreateMovementInput {
customerId: string;
domain: TransactionDomain;
amount: number;
transactionDate: string;
currency?: Currency;
typeId?: string;
period?: string;
reference?: string;
checkNumber?: string;
message?: string;
}
export interface MovementListItem extends Movement {
@@ -663,8 +856,10 @@ export interface CustomerDetail {
identificationExpiration: string | null;
customerSince: string | null;
status: boolean;
minimumBalance: string | number | null;
feeAmount: string | number | null;
preferredCurrency: string | null;
archivedAt: string | null;
legacyRefs: LegacyRef[];
properties: Property[];
policies: Policy[];
@@ -672,6 +867,30 @@ export interface CustomerDetail {
transactionSummary: TransactionSummaryRow[];
}
/** Editable customer fields — shared by the create/edit form and the API. */
export interface CustomerInput {
name: string;
addressLine1?: string;
addressLine2?: string;
city?: string;
state?: string;
zipCode?: string;
country?: string;
phone?: string;
mobile?: string;
fax?: string;
email?: string;
notes?: string;
identificationType?: string;
identificationNumber?: string;
identificationExpiration?: string;
customerSince?: string;
status?: boolean;
minimumBalance?: number;
feeAmount?: number;
preferredCurrency?: Currency;
}
/* ------------------------------------------------- Bank register (chequera) */
/**
@@ -707,6 +926,22 @@ export interface BankListItem {
/** "CIENTO CINCUENTA MIL PESOS 00/100" — egresos only. */
amountInWords: string | null;
source: string | null;
/** App-voided (`voidedAt` set). UI strikes; totals exclude. */
voided: boolean;
}
/** Payload for POST /bank — a new chequera movement. Sign convention: positive
* = ingreso, negative = egreso. MXN only. */
export interface CreateBankMovementInput {
amount: number;
transactionDate: string;
concept?: string;
reference?: string;
transactionType?: string;
cleared?: boolean;
transferred?: boolean;
notes?: string;
amountInWords?: string;
}
export interface BankTotals {
+22
View File
@@ -37,9 +37,14 @@ enum ServiceKind {
OTHER
}
/// Ordered access tier (rank): ADMIN > MANAGER > STAFF > VIEWER. VIEWER is the
/// read-only role; STAFF and above can write. Enforced by the API's ability
/// matrix (apps/api/src/auth/abilities.ts), not by the enum itself.
enum UserRole {
ADMIN
MANAGER
STAFF
VIEWER
}
// ---------------------------------------------------------------------------
@@ -78,6 +83,10 @@ model Customer {
minimumBalance Decimal? @db.Decimal(12, 2)
feeAmount Decimal? @db.Decimal(12, 2)
preferredCurrency Currency @default(USD)
// Soft-delete marker. Distinct from `status` (a legacy business flag): a
// non-null archivedAt hides the row from default lists while preserving it
// and its legacy provenance. Never hard-delete migrated data.
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -156,6 +165,9 @@ model Policy {
liquidated Boolean @default(false)
liquidationNumber String?
liquidationDate DateTime?
// Soft-delete marker (see Customer.archivedAt). Never hard-delete migrated
// policy data; archiving hides it from default lists.
archivedAt DateTime?
legacySourceDb String?
legacySourceTable String?
legacyId String?
@@ -309,6 +321,8 @@ model Property {
phone2 String?
phone3 String?
zone String?
// Soft-delete marker (see Customer.archivedAt).
archivedAt DateTime?
legacySourceTable String?
legacyId String?
createdAt DateTime @default(now())
@@ -395,6 +409,11 @@ model Transaction {
checkNumber String?
message String? @db.Text
outstanding Boolean @default(false)
// Append + void: booked rows are never edited or hard-deleted. A non-null
// voidedAt reverses the movement — it MUST be excluded from every balance
// and total (SUM/count) so a voided amount stops affecting the books.
voidedAt DateTime?
voidedById String?
legacySourceDb String?
legacySourceTable String?
legacyId String?
@@ -443,6 +462,9 @@ model BankTransaction {
transferred Boolean @default(false)
notes String? @db.Text
amountInWords String?
// Append + void (see Transaction.voidedAt): excluded from income/expense/net.
voidedAt DateTime?
voidedById String?
legacySourceTable String?
legacyId String?