-
+
+
+ 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 (
+
+ );
+}
+
+function Field({
+ label,
+ required,
+ children,
+}: {
+ label: string;
+ required?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 571344c..ef38d6a 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -17,6 +17,7 @@ import type {
BillingStats,
BusinessLine,
CustomerDetail,
+ CustomerInput,
CustomerListResponse,
CustomerStats,
LedgerCurrency,
@@ -132,6 +133,31 @@ export function getCustomer(id: string): Promise {
return apiFetch(`/customers/${id}`);
}
+export function createCustomer(input: CustomerInput): Promise {
+ return apiFetch("/customers", {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+
+export function updateCustomer(
+ id: string,
+ input: Partial,
+): Promise {
+ return apiFetch(`/customers/${id}`, {
+ method: "PATCH",
+ body: JSON.stringify(input),
+ });
+}
+
+export function archiveCustomer(id: string): Promise {
+ return apiFetch(`/customers/${id}`, { method: "DELETE" });
+}
+
+export function restoreCustomer(id: string): Promise {
+ return apiFetch(`/customers/${id}/restore`, { method: "POST" });
+}
+
/* ------------------------------------------------------ Policies module */
/** Renewal horizon in days, shared by the list, stats and detail calls so the
diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts
index a92f7e5..ea85e95 100644
--- a/apps/web/src/lib/types.ts
+++ b/apps/web/src/lib/types.ts
@@ -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) */
/**
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index dd5cbe1..2d21165 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -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