From 12692a0af80103a1c4da35a7c5944bfaa4dcdca5 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Thu, 23 Jul 2026 12:08:44 -0700 Subject: [PATCH] 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 --- apps/api/src/customers/create-customer.dto.ts | 42 +++ .../api/src/customers/customers.controller.ts | 75 ++++- apps/api/src/customers/customers.service.ts | 69 ++++- apps/api/src/customers/update-customer.dto.ts | 34 +++ .../web/src/app/clientes/[id]/editar/page.tsx | 58 ++++ apps/web/src/app/clientes/[id]/page.tsx | 63 ++++- apps/web/src/app/clientes/nuevo/page.tsx | 36 +++ apps/web/src/app/clientes/page.tsx | 13 +- apps/web/src/app/globals.css | 9 + apps/web/src/components/CustomerForm.tsx | 265 ++++++++++++++++++ apps/web/src/lib/api.ts | 26 ++ apps/web/src/lib/types.ts | 29 ++ packages/database/prisma/schema.prisma | 4 + 13 files changed, 715 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/customers/create-customer.dto.ts create mode 100644 apps/api/src/customers/update-customer.dto.ts create mode 100644 apps/web/src/app/clientes/[id]/editar/page.tsx create mode 100644 apps/web/src/app/clientes/nuevo/page.tsx create mode 100644 apps/web/src/components/CustomerForm.tsx diff --git a/apps/api/src/customers/create-customer.dto.ts b/apps/api/src/customers/create-customer.dto.ts new file mode 100644 index 0000000..e8e33f8 --- /dev/null +++ b/apps/api/src/customers/create-customer.dto.ts @@ -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; +} diff --git a/apps/api/src/customers/customers.controller.ts b/apps/api/src/customers/customers.controller.ts index 8ecbf7f..a23530e 100644 --- a/apps/api/src/customers/customers.controller.ts +++ b/apps/api/src/customers/customers.controller.ts @@ -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; + } } diff --git a/apps/api/src/customers/customers.service.ts b/apps/api/src/customers/customers.service.ts index a31c57c..63fa63c 100644 --- a/apps/api/src/customers/customers.service.ts +++ b/apps/api/src/customers/customers.service.ts @@ -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, @@ -133,6 +148,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] = diff --git a/apps/api/src/customers/update-customer.dto.ts b/apps/api/src/customers/update-customer.dto.ts new file mode 100644 index 0000000..21af1c6 --- /dev/null +++ b/apps/api/src/customers/update-customer.dto.ts @@ -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; +} diff --git a/apps/web/src/app/clientes/[id]/editar/page.tsx b/apps/web/src/app/clientes/[id]/editar/page.tsx new file mode 100644 index 0000000..904fc5a --- /dev/null +++ b/apps/web/src/app/clientes/[id]/editar/page.tsx @@ -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 ( + + + + ); +} + +function EditarCliente({ id }: { id: string }) { + const allowed = useCan("customer:update"); + const [customer, setCustomer] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!allowed) return; + getCustomer(id) + .then(setCustomer) + .catch((e) => setError(e?.message ?? "No se pudo cargar el cliente.")); + }, [id, allowed]); + + return ( + <> +
+ + ← Cliente + +

Editar cliente

+
+ {!allowed ? ( +
+ No tiene permisos para editar clientes. +
+ ) : error ? ( +
{error}
+ ) : !customer ? ( +
+ +
+ ) : ( + + )} + + ); +} diff --git a/apps/web/src/app/clientes/[id]/page.tsx b/apps/web/src/app/clientes/[id]/page.tsx index b13bf7c..d431c33 100644 --- a/apps/web/src/app/clientes/[id]/page.tsx +++ b/apps/web/src/app/clientes/[id]/page.tsx @@ -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,7 +87,13 @@ function Detail({ id }: { id: string }) { return (
- +
+ + getCustomer(id).then(setData).catch(() => {})} + /> +
@@ -110,6 +117,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 ( +
+ {archived && Archivado} + {canEdit && ( + + Editar + + )} + {canDelete && ( + + )} +
+ ); +} + /* ------------------------------------------------------------------ Hero */ function Hero({ data, diff --git a/apps/web/src/app/clientes/nuevo/page.tsx b/apps/web/src/app/clientes/nuevo/page.tsx new file mode 100644 index 0000000..45f2946 --- /dev/null +++ b/apps/web/src/app/clientes/nuevo/page.tsx @@ -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 ( + + + + ); +} + +function NuevoCliente() { + const allowed = useCan("customer:create"); + + return ( + <> +
+ + ← Clientes + +

Nuevo cliente

+
+ {allowed ? ( + + ) : ( +
+ No tiene permisos para crear clientes. +
+ )} + + ); +} diff --git a/apps/web/src/app/clientes/page.tsx b/apps/web/src/app/clientes/page.tsx index f49c5bb..21ca29c 100644 --- a/apps/web/src/app/clientes/page.tsx +++ b/apps/web/src/app/clientes/page.tsx @@ -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(null); + const canCreate = useCan("customer:create"); + const debounceRef = useRef>(); useEffect(() => { @@ -93,7 +96,15 @@ function ClientesBrowser() { <>

Directorio unificado

-

Clientes

+
+

Clientes

+ + {canCreate && ( + + + Nuevo cliente + + )} +
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 765b09e..b3ca463 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -299,8 +299,17 @@ button { .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); diff --git a/apps/web/src/components/CustomerForm.tsx b/apps/web/src/components/CustomerForm.tsx new file mode 100644 index 0000000..face633 --- /dev/null +++ b/apps/web/src/components/CustomerForm.tsx @@ -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(() => initial(customer)); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + function set(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 ( +
+ {error &&
{error}
} + +
+

Identidad

+
+ + set("name", e.target.value)} /> + + + set("email", e.target.value)} /> + + + set("phone", e.target.value)} /> + + + set("mobile", e.target.value)} /> + + + set("fax", e.target.value)} /> + + + set("customerSince", e.target.value)} /> + +
+
+ +
+

Domicilio

+
+ + set("addressLine1", e.target.value)} /> + + + set("addressLine2", e.target.value)} /> + + + set("city", e.target.value)} /> + + + set("state", e.target.value)} /> + + + set("zipCode", e.target.value)} /> + + + set("country", e.target.value)} /> + +
+
+ +
+

+ Identificación y cuenta +

+
+ + set("identificationType", e.target.value)} /> + + + set("identificationNumber", e.target.value)} /> + + + set("identificationExpiration", e.target.value)} /> + + + + + + set("minimumBalance", e.target.value)} /> + + + set("feeAmount", e.target.value)} /> + + + set("status", e.target.checked)} /> + +
+