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:
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user