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
@@ -20,7 +20,7 @@ function build(opts: { holder?: { name: string } | null; archived?: boolean; fol
|
|||||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve(data)),
|
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", () => {
|
describe("customer folder numbers", () => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("customer file ledger card", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
service: new CustomersService(prisma as never),
|
service: new CustomersService(prisma as never, {} as never),
|
||||||
prisma,
|
prisma,
|
||||||
groupBy,
|
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 { NumidService } from "./numid.service";
|
||||||
import { CreateCustomerDto } from "./create-customer.dto";
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
import { OnboardCustomerDto } from "./onboard-customer.dto";
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
@Controller("customers")
|
@Controller("customers")
|
||||||
@@ -83,6 +84,26 @@ export class CustomersController {
|
|||||||
return c;
|
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")
|
@Patch(":id")
|
||||||
@RequireAbility("customer:update")
|
@RequireAbility("customer:update")
|
||||||
async update(
|
async update(
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { SettingsModule } from "../settings/settings.module";
|
import { SettingsModule } from "../settings/settings.module";
|
||||||
|
import { CashReceiptsModule } from "../cash-receipts/cash-receipts.module";
|
||||||
import { CustomersController } from "./customers.controller";
|
import { CustomersController } from "./customers.controller";
|
||||||
import { CustomersService } from "./customers.service";
|
import { CustomersService } from "./customers.service";
|
||||||
import { NumidService } from "./numid.service";
|
import { NumidService } from "./numid.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [SettingsModule],
|
imports: [SettingsModule, CashReceiptsModule],
|
||||||
controllers: [CustomersController],
|
controllers: [CustomersController],
|
||||||
providers: [CustomersService, NumidService],
|
providers: [CustomersService, NumidService],
|
||||||
exports: [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 { Prisma } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { CreateCustomerDto } from "./create-customer.dto";
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
import type { OnboardCustomerDto } from "./onboard-customer.dto";
|
||||||
|
import { CashReceiptsService } from "../cash-receipts/cash-receipts.service";
|
||||||
import {
|
import {
|
||||||
BALANCE_FORWARD_TYPE,
|
BALANCE_FORWARD_TYPE,
|
||||||
notCashJournal,
|
notCashJournal,
|
||||||
@@ -51,7 +58,10 @@ function toDate(v?: string): Date | null | undefined {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomersService {
|
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. */
|
/** Unified customer list with search + business-line filter, paginated. */
|
||||||
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
|
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) {
|
async update(id: string, dto: UpdateCustomerDto) {
|
||||||
const current = await this.ensureExists(id);
|
const current = await this.ensureExists(id);
|
||||||
// An archived customer's number is history, not a claim on the shelf.
|
// 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 && (
|
{canCreate && (
|
||||||
<Link href="/clientes/nuevo" className="btn btn-primary">
|
<>
|
||||||
+ Nuevo cliente
|
<Link href="/clientes/nuevo" className="btn btn-outline">
|
||||||
</Link>
|
+ Solo cliente
|
||||||
|
</Link>
|
||||||
|
<Link href="/clientes/alta" className="btn btn-primary">
|
||||||
|
+ Alta de cliente
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<StatStrip stats={stats} />
|
<StatStrip stats={stats} />
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ import type {
|
|||||||
CashReceipt,
|
CashReceipt,
|
||||||
CashReceiptInput,
|
CashReceiptInput,
|
||||||
CompanyHeader,
|
CompanyHeader,
|
||||||
|
OnboardInput,
|
||||||
|
OnboardResult,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// Resolve the API origin at runtime, not build time — so one built image serves
|
// 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}`);
|
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> {
|
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
|
||||||
return apiFetch<CustomerDetail>("/customers", {
|
return apiFetch<CustomerDetail>("/customers", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1723,3 +1723,26 @@ export interface CashReceiptInput {
|
|||||||
concept: string;
|
concept: string;
|
||||||
depositedAt?: 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