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>
This commit is contained in:
2026-07-23 12:08:44 -07:00
co-authored by Claude Opus 4.8
parent 74e2ad8bcd
commit 12692a0af8
13 changed files with 715 additions and 8 deletions
@@ -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;
}
}
+68 -1
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,
@@ -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] =
@@ -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;
}
@@ -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} />
)}
</>
);
}
+61 -2
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,7 +87,13 @@ function Detail({ id }: { id: string }) {
return (
<div className="rise">
<BackLink />
<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} />
@@ -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 (
<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,
+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>
+9
View File
@@ -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);
+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>
);
}
+26
View File
@@ -17,6 +17,7 @@ import type {
BillingStats,
BusinessLine,
CustomerDetail,
CustomerInput,
CustomerListResponse,
CustomerStats,
LedgerCurrency,
@@ -132,6 +133,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
+29
View File
@@ -1,6 +1,8 @@
// 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 =
@@ -65,6 +67,7 @@ export interface CustomerListItem {
phone: string | null;
mobile: string | null;
status: boolean;
archived: boolean;
propertyCount: number;
policyCount: number;
transactionCount: number;
@@ -695,8 +698,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[];
@@ -704,6 +709,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) */
/**
+4
View File
@@ -83,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