Files
jorgecuadros-platform/apps/web/src/components/CustomerForm.tsx
T
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

266 lines
9.2 KiB
TypeScript

"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>
);
}