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
+567
-7
No files matched your search
@@ -20,7 +20,7 @@ function build(opts: { holder?: { name: string } | null; archived?: boolean; fol
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve(data)),
|
||||
},
|
||||
};
|
||||
return { service: new CustomersService(prisma as never), prisma };
|
||||
return { service: new CustomersService(prisma as never, {} as never), prisma };
|
||||
}
|
||||
|
||||
describe("customer folder numbers", () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("customer file ledger card", () => {
|
||||
},
|
||||
};
|
||||
return {
|
||||
service: new CustomersService(prisma as never),
|
||||
service: new CustomersService(prisma as never, {} as never),
|
||||
prisma,
|
||||
groupBy,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import { CustomersService } from "./customers.service";
|
||||
|
||||
/**
|
||||
* Onboarding writes four tables in one transaction; what is checked here is
|
||||
* that a request it can't complete is refused before anything is written, and
|
||||
* that a cash opening balance goes through the receipt book inside that same
|
||||
* transaction. The rollback itself was exercised against the dev database.
|
||||
*/
|
||||
|
||||
function build() {
|
||||
const tx = {
|
||||
customer: { create: jest.fn().mockResolvedValue({ id: "c1" }) },
|
||||
property: { create: jest.fn().mockResolvedValue({ id: "p1" }) },
|
||||
propertyService: { createMany: jest.fn() },
|
||||
typeTransaction: { findFirst: jest.fn() },
|
||||
transaction: { create: jest.fn() },
|
||||
};
|
||||
const prisma = {
|
||||
customer: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
$transaction: jest.fn((fn) => fn(tx)),
|
||||
};
|
||||
const cash = { create: jest.fn().mockResolvedValue({ folio: 13935 }) };
|
||||
return { service: new CustomersService(prisma as never, cash as never), prisma, tx, cash };
|
||||
}
|
||||
|
||||
const pay = { amount: 500, currency: "MXN" as const, receivedAt: "2026-10-12" };
|
||||
|
||||
describe("customer onboarding", () => {
|
||||
it("refuses a check opening balance with no check number, writing nothing", async () => {
|
||||
const { service, prisma } = build();
|
||||
await expect(
|
||||
service.onboard({ customer: { name: "X" }, openingPayment: { ...pay, method: "CHECK" } }, "u1"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses services with no property to hang them on", async () => {
|
||||
const { service, prisma } = build();
|
||||
await expect(
|
||||
service.onboard({ customer: { name: "X" }, services: [{ kind: "WATER" }] }, "u1"),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("issues the cash receipt inside the onboarding transaction", async () => {
|
||||
const { service, tx, cash } = build();
|
||||
const r = await service.onboard(
|
||||
{
|
||||
customer: { name: "X" },
|
||||
property: { addressLine1: "Calle 1" },
|
||||
services: [{ kind: "WATER", accountNumber: "3014057" }],
|
||||
openingPayment: { ...pay, method: "CASH" },
|
||||
},
|
||||
"u1",
|
||||
);
|
||||
|
||||
expect(cash.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ customerId: "c1", amount: 500, concept: "SALDO INICIAL" }),
|
||||
"u1",
|
||||
tx,
|
||||
);
|
||||
expect(tx.propertyService.createMany).toHaveBeenCalledWith({
|
||||
data: [{ kind: "WATER", accountNumber: "3014057", propertyId: "p1" }],
|
||||
});
|
||||
expect(r.receipt).toEqual({ folio: 13935 });
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { CustomersService } from "./customers.service";
|
||||
import { NumidService } from "./numid.service";
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
import { OnboardCustomerDto } from "./onboard-customer.dto";
|
||||
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("customers")
|
||||
@@ -83,6 +84,26 @@ export class CustomersController {
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alta en una pantalla. Gated on customer:create; the property, service and
|
||||
* ledger abilities it also exercises sit at the same STAFF tier.
|
||||
*/
|
||||
@Post("onboard")
|
||||
@RequireAbility("customer:create")
|
||||
async onboard(@Body() dto: OnboardCustomerDto, @Req() req: Request) {
|
||||
const r = await this.customers.onboard(dto, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "customer.onboard", {
|
||||
customerId: r.customer.id,
|
||||
name: r.customer.name,
|
||||
folderNumber: r.customer.folderNumber,
|
||||
propertyId: r.property?.id,
|
||||
services: dto.services?.length ?? 0,
|
||||
folio: r.receipt?.folio,
|
||||
openingTransactionId: r.receipt?.transactionId ?? r.payment?.id,
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequireAbility("customer:update")
|
||||
async update(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { SettingsModule } from "../settings/settings.module";
|
||||
import { CashReceiptsModule } from "../cash-receipts/cash-receipts.module";
|
||||
import { CustomersController } from "./customers.controller";
|
||||
import { CustomersService } from "./customers.service";
|
||||
import { NumidService } from "./numid.service";
|
||||
|
||||
@Module({
|
||||
imports: [SettingsModule],
|
||||
imports: [SettingsModule, CashReceiptsModule],
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService, NumidService],
|
||||
exports: [NumidService],
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||
import type { OnboardCustomerDto } from "./onboard-customer.dto";
|
||||
import { CashReceiptsService } from "../cash-receipts/cash-receipts.service";
|
||||
import {
|
||||
BALANCE_FORWARD_TYPE,
|
||||
notCashJournal,
|
||||
@@ -51,7 +58,10 @@ function toDate(v?: string): Date | null | undefined {
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly cashReceipts: CashReceiptsService,
|
||||
) {}
|
||||
|
||||
/** Unified customer list with search + business-line filter, paginated. */
|
||||
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
|
||||
@@ -259,6 +269,81 @@ export class CustomersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an account from one screen: customer, property, services and the
|
||||
* opening balance taken as the first payment. One database transaction — a
|
||||
* half-created account (a customer with no property, a payment with no
|
||||
* customer) is worse than none, because the office would key it again.
|
||||
*/
|
||||
async onboard(dto: OnboardCustomerDto, userId: string) {
|
||||
const pay = dto.openingPayment;
|
||||
if (pay) {
|
||||
if (!(pay.amount > 0)) throw new BadRequestException("El saldo inicial debe ser mayor a cero.");
|
||||
if (pay.method === "CHECK" && !pay.checkNumber?.trim()) {
|
||||
throw new BadRequestException("Falta el número de cheque del saldo inicial.");
|
||||
}
|
||||
}
|
||||
if (dto.services?.length && !dto.property) {
|
||||
throw new BadRequestException("Los servicios necesitan una propiedad.");
|
||||
}
|
||||
await this.assertFolderFree(dto.customer.folderNumber);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const customer = await tx.customer.create({
|
||||
data: { ...this.toData(dto.customer), name: dto.customer.name, nameMissing: false },
|
||||
});
|
||||
|
||||
const property = dto.property
|
||||
? await tx.property.create({ data: { ...dto.property, customerId: customer.id } })
|
||||
: null;
|
||||
if (property && dto.services?.length) {
|
||||
await tx.propertyService.createMany({
|
||||
data: dto.services.map((s) => ({ ...s, propertyId: property.id })),
|
||||
});
|
||||
}
|
||||
|
||||
const concept = pay?.concept?.trim() || "SALDO INICIAL";
|
||||
let receipt = null;
|
||||
let payment = null;
|
||||
if (pay?.method === "CASH") {
|
||||
receipt = await this.cashReceipts.create(
|
||||
{
|
||||
customerId: customer.id,
|
||||
receivedAt: pay.receivedAt,
|
||||
amount: pay.amount,
|
||||
currency: pay.currency,
|
||||
exchangeRate: pay.exchangeRate,
|
||||
concept,
|
||||
},
|
||||
userId,
|
||||
tx,
|
||||
);
|
||||
} else if (pay?.method === "CHECK") {
|
||||
const date = new Date(pay.receivedAt);
|
||||
if (isNaN(date.getTime())) throw new BadRequestException("Fecha del saldo inicial inválida.");
|
||||
const type = await tx.typeTransaction.findFirst({
|
||||
where: { nameEn: "CHECK DEPOSIT" },
|
||||
select: { id: true },
|
||||
});
|
||||
payment = await tx.transaction.create({
|
||||
data: {
|
||||
customerId: customer.id,
|
||||
domain: "UTILITY",
|
||||
typeId: type?.id,
|
||||
transactionDate: date,
|
||||
amount: new Prisma.Decimal(pay.amount.toFixed(2)),
|
||||
currency: pay.currency,
|
||||
checkNumber: pay.checkNumber!.trim(),
|
||||
message: concept,
|
||||
captureSource: "MANUAL",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { customer, property, receipt, payment };
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCustomerDto) {
|
||||
const current = await this.ensureExists(id);
|
||||
// An archived customer's number is history, not a claim on the shelf.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { Currency } from "@jorgecuadros/database";
|
||||
import { CreateCustomerDto } from "./create-customer.dto";
|
||||
import { ServiceDto } from "../properties/property.dto";
|
||||
|
||||
/** The property, minus the customer it is being created for. */
|
||||
export class OnboardPropertyDto {
|
||||
@IsOptional() @IsString() addressLine1?: string;
|
||||
@IsOptional() @IsString() addressLine2?: string;
|
||||
@IsOptional() @IsString() phone1?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The opening balance the office takes when an account is opened, captured as
|
||||
* the customer's first payment. Cash issues an EFE receipt; a check posts a
|
||||
* CHECK DEPOSIT against that check number.
|
||||
*/
|
||||
export class OpeningPaymentDto {
|
||||
@IsIn(["CASH", "CHECK"]) method!: "CASH" | "CHECK";
|
||||
@IsNumber() amount!: number;
|
||||
@IsEnum(Currency) currency!: Currency;
|
||||
@IsString() @MinLength(1) receivedAt!: string;
|
||||
@IsOptional() @IsString() checkNumber?: string;
|
||||
/** Cash only: dollars credited to a pesos ledger. */
|
||||
@IsOptional() @IsNumber() exchangeRate?: number;
|
||||
@IsOptional() @IsString() concept?: string;
|
||||
}
|
||||
|
||||
/** One screen: cliente + propiedad + servicios + saldo inicial. */
|
||||
export class OnboardCustomerDto {
|
||||
@ValidateNested() @Type(() => CreateCustomerDto) customer!: CreateCustomerDto;
|
||||
|
||||
@IsOptional() @ValidateNested() @Type(() => OnboardPropertyDto)
|
||||
property?: OnboardPropertyDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(12)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ServiceDto)
|
||||
services?: ServiceDto[];
|
||||
|
||||
@IsOptional() @ValidateNested() @Type(() => OpeningPaymentDto)
|
||||
openingPayment?: OpeningPaymentDto;
|
||||
}
|
||||
@@ -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 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