feat(clientes): alta de cliente in one screen, opening balance as a payment
The 2026-10-08 ask: open an account without four screens — cliente, propiedad, servicios and the saldo inicial, taken as the first payment. POST /customers/onboard writes it all in one Prisma transaction; a bad service or a taken folder number leaves nothing behind (rolled back on dev). A cash opening balance goes through the EFE receipt book inside that same transaction, so it gets its folio and its C<folio> credit; a check posts a CHECK DEPOSIT against the number given. /clientes/alta pre-fills the next free folder number and links the receipt for printing. The old customer-only form stays as "Solo cliente". Built without the screen sketch from the meeting (it never arrived); the fields are the legacy ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca76f4d57a
commit
6468811955
11 files changed
+568
-8
No files matched your search
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { getNextFolderNumber, onboardCustomer } from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatMoney, SERVICE_KIND_LABELS } from "@/lib/labels";
|
||||
import type { Currency, OnboardInput, OnboardResult, ServiceKind } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Alta de cliente in one screen (2026-10-08 meeting): the customer, their
|
||||
* property, its services and the opening balance taken as the first payment,
|
||||
* saved together or not at all. Cash issues an EFE receipt with its folio.
|
||||
*/
|
||||
export default function AltaClientePage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Alta />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
type ServiceRow = { kind: ServiceKind; accountNumber: string; meterNumber: string };
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const t = (v: string) => v.trim() || undefined;
|
||||
|
||||
function Alta() {
|
||||
const allowed = useCan("customer:create");
|
||||
|
||||
const [folder, setFolder] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [preferredCurrency, setPreferredCurrency] = useState<Currency>("USD");
|
||||
|
||||
const [address1, setAddress1] = useState("");
|
||||
const [address2, setAddress2] = useState("");
|
||||
const [propertyPhone, setPropertyPhone] = useState("");
|
||||
const [services, setServices] = useState<ServiceRow[]>([]);
|
||||
|
||||
const [payAmount, setPayAmount] = useState("");
|
||||
const [payMethod, setPayMethod] = useState<"CASH" | "CHECK">("CASH");
|
||||
const [payCurrency, setPayCurrency] = useState<Currency>("MXN");
|
||||
const [payDate, setPayDate] = useState(today);
|
||||
const [checkNumber, setCheckNumber] = useState("");
|
||||
const [exchangeRate, setExchangeRate] = useState("");
|
||||
const [concept, setConcept] = useState("");
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<OnboardResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getNextFolderNumber()
|
||||
.then((r) => setFolder((prev) => prev || String(r.folderNumber)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const hasProperty = !!(address1.trim() || address2.trim() || propertyPhone.trim() || services.length);
|
||||
const paying = Number(payAmount) > 0;
|
||||
|
||||
function setService(i: number, patch: Partial<ServiceRow>) {
|
||||
setServices((prev) => prev.map((s, j) => (j === i ? { ...s, ...patch } : s)));
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const input: OnboardInput = {
|
||||
customer: {
|
||||
name: name.trim(),
|
||||
folderNumber: folder ? Number(folder) : undefined,
|
||||
email: t(email),
|
||||
phone: t(phone),
|
||||
mobile: t(mobile),
|
||||
preferredCurrency,
|
||||
},
|
||||
...(hasProperty && {
|
||||
property: { addressLine1: t(address1), addressLine2: t(address2), phone1: t(propertyPhone) },
|
||||
services: services.map((s) => ({
|
||||
kind: s.kind,
|
||||
accountNumber: t(s.accountNumber),
|
||||
meterNumber: t(s.meterNumber),
|
||||
})),
|
||||
}),
|
||||
...(paying && {
|
||||
openingPayment: {
|
||||
method: payMethod,
|
||||
amount: Number(payAmount),
|
||||
currency: payCurrency,
|
||||
receivedAt: payDate,
|
||||
checkNumber: payMethod === "CHECK" ? t(checkNumber) : undefined,
|
||||
exchangeRate:
|
||||
payMethod === "CASH" && payCurrency === "USD" && exchangeRate
|
||||
? Number(exchangeRate)
|
||||
: undefined,
|
||||
concept: t(concept),
|
||||
},
|
||||
}),
|
||||
};
|
||||
try {
|
||||
setDone(await onboardCustomer(input));
|
||||
} catch (e2) {
|
||||
setError((e2 as Error)?.message ?? "No se pudo dar de alta al cliente.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return <div className="state-box state-error">No tiene permisos para crear clientes.</div>;
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="stack">
|
||||
<h1 className="page-title">Cliente dado de alta</h1>
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<p>
|
||||
<strong>{done.customer.name}</strong>
|
||||
{done.customer.folderNumber != null && ` · Carpeta ${done.customer.folderNumber}`}
|
||||
</p>
|
||||
{done.receipt && (
|
||||
<p>
|
||||
Saldo inicial recibido en efectivo, folio {done.receipt.folio}.{" "}
|
||||
<Link href={`/efectivo/${done.receipt.folio}`} target="_blank">
|
||||
Imprimir recibo
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{done.payment && <p>Saldo inicial registrado con el cheque {checkNumber}.</p>}
|
||||
<div className="form-actions">
|
||||
<Link href={`/clientes/${done.customer.id}`} className="btn btn-primary">
|
||||
Ver cliente
|
||||
</Link>
|
||||
<button type="button" className="btn btn-outline" onClick={() => location.reload()}>
|
||||
Otra alta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<div className="page-head">
|
||||
<Link href="/clientes" className="back-link">← Clientes</Link>
|
||||
<h1 className="page-title">Alta de cliente</h1>
|
||||
</div>
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Cliente</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Carpeta">
|
||||
<input className="input" type="number" min={1} value={folder}
|
||||
onChange={(e) => setFolder(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Nombre *">
|
||||
<input className="input" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Correo">
|
||||
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Teléfono">
|
||||
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Celular">
|
||||
<input className="input" value={mobile} onChange={(e) => setMobile(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Moneda preferida">
|
||||
<select className="select" value={preferredCurrency}
|
||||
onChange={(e) => setPreferredCurrency(e.target.value as Currency)}>
|
||||
<option value="USD">USD</option>
|
||||
<option value="MXN">MXN</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Propiedad y servicios</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Dirección 1">
|
||||
<input className="input" value={address1} onChange={(e) => setAddress1(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Dirección 2">
|
||||
<input className="input" value={address2} onChange={(e) => setAddress2(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Teléfono de la propiedad">
|
||||
<input className="input" value={propertyPhone} onChange={(e) => setPropertyPhone(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
{services.map((s, i) => (
|
||||
<div key={i} className="inline-form" style={{ flexWrap: "wrap", gap: 12, marginTop: 12 }}>
|
||||
<select className="select" value={s.kind}
|
||||
onChange={(e) => setService(i, { kind: e.target.value as ServiceKind })}>
|
||||
{Object.entries(SERVICE_KIND_LABELS).map(([k, label]) => (
|
||||
<option key={k} value={k}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input className="input" placeholder="Cuenta / referencia" value={s.accountNumber}
|
||||
onChange={(e) => setService(i, { accountNumber: e.target.value })} />
|
||||
<input className="input" placeholder="Medidor" value={s.meterNumber}
|
||||
onChange={(e) => setService(i, { meterNumber: e.target.value })} />
|
||||
<button type="button" className="btn btn-ghost"
|
||||
onClick={() => setServices((prev) => prev.filter((_, j) => j !== i))}>
|
||||
Quitar
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-ghost" style={{ marginTop: 12 }}
|
||||
onClick={() => setServices((prev) => [...prev, { kind: "WATER", accountNumber: "", meterNumber: "" }])}>
|
||||
+ Agregar servicio
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Saldo inicial (pago)</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Monto">
|
||||
<input className="input" type="number" step="0.01" min="0" value={payAmount}
|
||||
placeholder="Vacío = sin saldo inicial"
|
||||
onChange={(e) => setPayAmount(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Moneda">
|
||||
<select className="select" value={payCurrency}
|
||||
onChange={(e) => setPayCurrency(e.target.value as Currency)}>
|
||||
<option value="MXN">MXN</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Forma de pago">
|
||||
<select className="select" value={payMethod}
|
||||
onChange={(e) => setPayMethod(e.target.value as "CASH" | "CHECK")}>
|
||||
<option value="CASH">Efectivo (emite recibo EFE)</option>
|
||||
<option value="CHECK">Cheque</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Fecha">
|
||||
<input className="input" type="date" value={payDate} onChange={(e) => setPayDate(e.target.value)} />
|
||||
</Field>
|
||||
{payMethod === "CHECK" && (
|
||||
<Field label="Número de cheque *">
|
||||
<input className="input" required={paying} value={checkNumber}
|
||||
onChange={(e) => setCheckNumber(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{payMethod === "CASH" && payCurrency === "USD" && (
|
||||
<Field label="Tipo de cambio (abonar en pesos)">
|
||||
<input className="input" type="number" step="0.0001" min="0" value={exchangeRate}
|
||||
placeholder="Vacío = abonar en dólares"
|
||||
onChange={(e) => setExchangeRate(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Concepto">
|
||||
<input className="input" placeholder="SALDO INICIAL" value={concept}
|
||||
onChange={(e) => setConcept(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
{paying && (
|
||||
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||
Se abonará {formatMoney(payAmount, payCurrency)} a la cuenta del cliente
|
||||
{payMethod === "CASH" ? " y se emitirá el recibo de efectivo con el siguiente folio." : "."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy || !name.trim()}>
|
||||
{busy ? "Guardando…" : "Dar de alta"}
|
||||
</button>
|
||||
<Link href="/clientes" className="btn btn-outline">Cancelar</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -107,9 +107,14 @@ function ClientesBrowser() {
|
||||
]}
|
||||
/>
|
||||
{canCreate && (
|
||||
<Link href="/clientes/nuevo" className="btn btn-primary">
|
||||
+ Nuevo cliente
|
||||
</Link>
|
||||
<>
|
||||
<Link href="/clientes/nuevo" className="btn btn-outline">
|
||||
+ Solo cliente
|
||||
</Link>
|
||||
<Link href="/clientes/alta" className="btn btn-primary">
|
||||
+ Alta de cliente
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<StatStrip stats={stats} />
|
||||
|
||||
@@ -84,6 +84,8 @@ import type {
|
||||
CashReceipt,
|
||||
CashReceiptInput,
|
||||
CompanyHeader,
|
||||
OnboardInput,
|
||||
OnboardResult,
|
||||
} from "./types";
|
||||
|
||||
// Resolve the API origin at runtime, not build time — so one built image serves
|
||||
@@ -228,6 +230,14 @@ export function getCustomer(id: string): Promise<CustomerDetail> {
|
||||
return apiFetch<CustomerDetail>(`/customers/${id}`);
|
||||
}
|
||||
|
||||
/** Open an account in one step; see OnboardInput. */
|
||||
export function onboardCustomer(input: OnboardInput): Promise<OnboardResult> {
|
||||
return apiFetch<OnboardResult>("/customers/onboard", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
|
||||
return apiFetch<CustomerDetail>("/customers", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1723,3 +1723,26 @@ export interface CashReceiptInput {
|
||||
concept: string;
|
||||
depositedAt?: string;
|
||||
}
|
||||
|
||||
/** One-screen account opening: cliente + propiedad + servicios + saldo inicial. */
|
||||
export interface OnboardInput {
|
||||
customer: CustomerInput;
|
||||
property?: { addressLine1?: string; addressLine2?: string; phone1?: string };
|
||||
services?: { kind: ServiceKind; accountNumber?: string; meterNumber?: string }[];
|
||||
openingPayment?: {
|
||||
method: "CASH" | "CHECK";
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
receivedAt: string;
|
||||
checkNumber?: string;
|
||||
exchangeRate?: number;
|
||||
concept?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OnboardResult {
|
||||
customer: { id: string; name: string; folderNumber: number | null };
|
||||
property: { id: string } | null;
|
||||
receipt: { folio: number } | null;
|
||||
payment: { id: string } | null;
|
||||
}
|
||||
Reference in new issue
Block a user