feat(clientes): alta de cliente in one screen, opening balance as a payment
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m57s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m51s

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:
rmancinasandClaude Opus 5.5 committed 2026-10-12 19:27:00 -07:00
1 parent ca76f4d57a
commit 6468811955
11 files changed
+568 -8

No files matched your search

+291
View File
@@ -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>
);
}
+8 -3
View File
@@ -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} />
+10
View File
@@ -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",
+23
View File
@@ -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;
}