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)} /> + +
+