feat(efectivo): the EFE cash receipt book, folio and posting in one step
The office still issues cash receipts from Access (EFE MENU -> EFE). This is that form: cliente, fecha, monto + moneda, concepto, elaboró (the logged-in user), fecha de depósito, and a folio that continues the legacy book. - cash_receipts, folio unique. Next folio = one past the highest of the platform's receipts and the imported utilities EFECTIVO journal, so after the cutover import it lands on the office's next number (13935 on prod today). A typed folio the legacy book already used is refused. - Issuing a receipt posts its credit in the same database transaction, as legacy posted to DATOS2: reference C<folio>, CASH DEPOSIT, pseudo check CASH<MMDD><P|D>. Dollars with a rate credit a pesos ledger (1,500 USD at 16.80 -> 25,200 MXN, as C13830 did). Voiding that row voids the receipt. - /efectivo: issue, search, record the deposit date; /efectivo/<folio> prints the receipt. - Reporte de efectivo now lists platform receipts by receipt, not by check, so each is counted once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fd5df240bc
commit
ca76f4d57a
14 files changed
+959
-3
No files matched your search
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getCashReceipt } from "@/lib/api";
|
||||
import { formatDate, formatMoney } from "@/lib/labels";
|
||||
import type { CashReceipt, CompanyHeader } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* One EFE receipt, laid out to print and hand to the customer. No app shell:
|
||||
* the page is the paper.
|
||||
*/
|
||||
export default function CashReceiptPrint({ params }: { params: { folio: string } }) {
|
||||
const [r, setR] = useState<(CashReceipt & { company: CompanyHeader }) | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getCashReceipt(Number(params.folio))
|
||||
.then(setR)
|
||||
.catch((e) => setError((e as Error)?.message ?? "No se encontró el recibo."));
|
||||
}, [params.folio]);
|
||||
|
||||
if (error) return <div className="state-box state-error">{error}</div>;
|
||||
if (!r) return <div className="state-box">Cargando…</div>;
|
||||
|
||||
const c = r.company;
|
||||
const voided = r.transaction.voidedAt != null;
|
||||
const converted = r.exchangeRate != null;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720, margin: "24px auto", padding: 24, fontSize: 14 }}>
|
||||
<style>{"@media print { .no-print { display: none } body { background: #fff } }"}</style>
|
||||
<div className="no-print" style={{ marginBottom: 16 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={() => window.print()}>
|
||||
Imprimir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ border: "1px solid #333", padding: 24 }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 16 }}>
|
||||
<div>
|
||||
<strong style={{ fontSize: 18 }}>{c.name}</strong>
|
||||
<div>{c.addressLine1}</div>
|
||||
{c.addressLine2 && <div>{c.addressLine2}</div>}
|
||||
<div>{c.cityState}</div>
|
||||
{c.mailingAddress && <div>{c.mailingAddress}</div>}
|
||||
<div>
|
||||
{c.phone} · {c.email}
|
||||
</div>
|
||||
{c.taxId && <div>RFC {c.taxId}</div>}
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>RECIBO DE EFECTIVO</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700 }}>Folio {r.folio}</div>
|
||||
<div>{formatDate(r.receivedAt)}</div>
|
||||
{voided && <div style={{ color: "#b00", fontWeight: 700 }}>ANULADO</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table style={{ width: "100%", marginTop: 24, borderCollapse: "collapse" }}>
|
||||
<tbody>
|
||||
<Row label="Cliente">
|
||||
{r.customer.name}
|
||||
{r.customer.folderNumber != null && ` · Carpeta ${r.customer.folderNumber}`}
|
||||
</Row>
|
||||
<Row label="Monto">
|
||||
<strong>{formatMoney(r.amount, r.currency)}</strong>
|
||||
{converted &&
|
||||
` (abonado ${formatMoney(r.transaction.amount, r.transaction.currency)} a T.C. ${Number(r.exchangeRate).toFixed(4)})`}
|
||||
</Row>
|
||||
<Row label="Concepto">{r.concept}</Row>
|
||||
<Row label="Elaboró">{r.preparedBy.name}</Row>
|
||||
{r.depositedAt && <Row label="Fecha de depósito">{formatDate(r.depositedAt)}</Row>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ marginTop: 56, display: "flex", justifyContent: "space-between" }}>
|
||||
<div style={{ borderTop: "1px solid #333", width: 240, textAlign: "center", paddingTop: 4 }}>
|
||||
Recibí
|
||||
</div>
|
||||
<div style={{ borderTop: "1px solid #333", width: 240, textAlign: "center", paddingTop: 4 }}>
|
||||
Entregó
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ padding: "6px 12px 6px 0", width: 160, color: "#555", verticalAlign: "top" }}>
|
||||
{label}
|
||||
</td>
|
||||
<td style={{ padding: "6px 0" }}>{children}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import {
|
||||
createCashReceipt,
|
||||
getNextCashFolio,
|
||||
listCashReceipts,
|
||||
setCashReceiptDeposit,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, formatMoney } from "@/lib/labels";
|
||||
import type { CashReceipt, Currency } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* The EFE cash receipt book — the legacy EFE form. Each receipt takes the next
|
||||
* folio, prints for the customer, and credits their account as C<folio> in the
|
||||
* same step. Elaboró is whoever is logged in.
|
||||
*/
|
||||
export default function EfectivoPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<CashBook />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
function CashBook() {
|
||||
const canCapture = useCan("ledger:create");
|
||||
const [query, setQuery] = useState("");
|
||||
const [items, setItems] = useState<CashReceipt[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await listCashReceipts({ query: query.trim() || undefined });
|
||||
setItems(r.items);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudieron cargar los recibos.");
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => void load(), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Recibos de efectivo</h1>
|
||||
<p className="page-sub">
|
||||
Cada recibo toma el siguiente folio y abona la cuenta del cliente como C‹folio›.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{canCapture && <ReceiptForm onIssued={load} />}
|
||||
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder="Buscar por folio, carpeta o cliente…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{ marginBottom: 12, maxWidth: 360 }}
|
||||
/>
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Folio</th>
|
||||
<th>Fecha</th>
|
||||
<th>Carpeta</th>
|
||||
<th>Cliente</th>
|
||||
<th style={{ textAlign: "right" }}>Monto</th>
|
||||
<th>Concepto</th>
|
||||
<th>Elaboró</th>
|
||||
<th>Depósito</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => (
|
||||
<ReceiptRow key={r.id} r={r} canCapture={canCapture} onChange={load} />
|
||||
))}
|
||||
{!items.length && (
|
||||
<tr>
|
||||
<td colSpan={9} className="page-sub">Sin recibos.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiptForm({ onIssued }: { onIssued: () => void }) {
|
||||
const [customerId, setCustomerId] = useState("");
|
||||
const [customerName, setCustomerName] = useState("");
|
||||
const [folio, setFolio] = useState("");
|
||||
const [receivedAt, setReceivedAt] = useState(today);
|
||||
const [amount, setAmount] = useState("");
|
||||
const [currency, setCurrency] = useState<Currency>("MXN");
|
||||
const [exchangeRate, setExchangeRate] = useState("");
|
||||
const [concept, setConcept] = useState("");
|
||||
const [depositedAt, setDepositedAt] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [issued, setIssued] = useState<CashReceipt | null>(null);
|
||||
|
||||
const refreshFolio = useCallback(() => {
|
||||
getNextCashFolio()
|
||||
.then((r) => setFolio(String(r.folio)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
useEffect(refreshFolio, [refreshFolio]);
|
||||
|
||||
// The wording the office already writes on every receipt.
|
||||
const suggested = amount
|
||||
? currency === "USD"
|
||||
? `RECEIVED $${amount} DLLS FOR ACCOUNT.`
|
||||
: `RECIBI $${amount} PESOS PARA CUENTA.`
|
||||
: "";
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await createCashReceipt({
|
||||
customerId,
|
||||
folio: folio ? Number(folio) : undefined,
|
||||
receivedAt,
|
||||
amount: Number(amount),
|
||||
currency,
|
||||
exchangeRate: currency === "USD" && exchangeRate ? Number(exchangeRate) : undefined,
|
||||
concept: concept.trim() || suggested,
|
||||
depositedAt: depositedAt || undefined,
|
||||
});
|
||||
setIssued(r);
|
||||
setCustomerId("");
|
||||
setCustomerName("");
|
||||
setAmount("");
|
||||
setExchangeRate("");
|
||||
setConcept("");
|
||||
setDepositedAt("");
|
||||
refreshFolio();
|
||||
onIssued();
|
||||
} catch (e2) {
|
||||
setError((e2 as Error)?.message ?? "No se pudo emitir el recibo.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="card" style={{ padding: 16 }} onSubmit={submit}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>Nuevo recibo</h2>
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
{issued && (
|
||||
<div className="state-box" style={{ marginBottom: 12 }}>
|
||||
Folio {issued.folio} emitido a {issued.customer.name} por{" "}
|
||||
{formatMoney(issued.amount, issued.currency)}.{" "}
|
||||
<Link href={`/efectivo/${issued.folio}`} target="_blank">
|
||||
Imprimir
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Folio</span>
|
||||
<input className="input" type="number" min={1} value={folio}
|
||||
onChange={(e) => setFolio(e.target.value)} />
|
||||
</label>
|
||||
<div className="field" style={{ minWidth: 260 }}>
|
||||
<span className="field-label">Cliente *</span>
|
||||
<CustomerPicker
|
||||
value={customerId}
|
||||
valueName={customerName}
|
||||
onPick={(id, name) => {
|
||||
setCustomerId(id);
|
||||
setCustomerName(name);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span className="field-label">Fecha *</span>
|
||||
<input className="input" type="date" required value={receivedAt}
|
||||
onChange={(e) => setReceivedAt(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Monto *</span>
|
||||
<input className="input" type="number" step="0.01" min="0.01" required value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Moneda</span>
|
||||
<select className="select" value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||
<option value="MXN">MXN</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</label>
|
||||
{currency === "USD" && (
|
||||
<label className="field">
|
||||
<span className="field-label">Tipo de cambio (abonar en pesos)</span>
|
||||
<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)} />
|
||||
</label>
|
||||
)}
|
||||
<label className="field">
|
||||
<span className="field-label">Fecha de depósito</span>
|
||||
<input className="input" type="date" value={depositedAt}
|
||||
onChange={(e) => setDepositedAt(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field" style={{ marginTop: 12 }}>
|
||||
<span className="field-label">Concepto</span>
|
||||
<input className="input" value={concept} placeholder={suggested}
|
||||
onChange={(e) => setConcept(e.target.value)} />
|
||||
</label>
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary"
|
||||
disabled={busy || !customerId || !(Number(amount) > 0)}>
|
||||
{busy ? "Emitiendo…" : "Emitir recibo"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ReceiptRow({
|
||||
r,
|
||||
canCapture,
|
||||
onChange,
|
||||
}: {
|
||||
r: CashReceipt;
|
||||
canCapture: boolean;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const [deposit, setDeposit] = useState("");
|
||||
const voided = r.transaction.voidedAt != null;
|
||||
|
||||
async function saveDeposit() {
|
||||
await setCashReceiptDeposit(r.id, deposit);
|
||||
onChange();
|
||||
}
|
||||
|
||||
return (
|
||||
<tr style={voided ? { textDecoration: "line-through", opacity: 0.6 } : undefined}>
|
||||
<td>{r.folio}</td>
|
||||
<td>{formatDate(r.receivedAt)}</td>
|
||||
<td>{r.customer.folderNumber ?? "—"}</td>
|
||||
<td>
|
||||
<Link href={`/clientes/${r.customer.id}`}>{r.customer.name}</Link>
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>{formatMoney(r.amount, r.currency)}</td>
|
||||
<td>{r.concept}</td>
|
||||
<td>{r.preparedBy.name}</td>
|
||||
<td>
|
||||
{r.depositedAt ? (
|
||||
formatDate(r.depositedAt)
|
||||
) : canCapture && !voided ? (
|
||||
<span className="inline-form" style={{ gap: 4 }}>
|
||||
<input className="input" type="date" value={deposit}
|
||||
onChange={(e) => setDeposit(e.target.value)} />
|
||||
<button type="button" className="btn btn-ghost" disabled={!deposit}
|
||||
onClick={saveDeposit}>
|
||||
Guardar
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{voided ? "Anulado" : <Link href={`/efectivo/${r.folio}`} target="_blank">Imprimir</Link>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -66,6 +66,8 @@ const NAV: NavEntry[] = [
|
||||
ability: "ledger:create",
|
||||
aliases: ["/recibos"],
|
||||
},
|
||||
// The EFE cash receipt book (legacy EFE MENU → EFE).
|
||||
{ href: "/efectivo", label: "Recibos de efectivo", ability: "ledger:create" },
|
||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||
{ href: "/banco", label: "Chequera" },
|
||||
],
|
||||
|
||||
@@ -81,6 +81,9 @@ import type {
|
||||
TransactionDomain,
|
||||
TrustFilter,
|
||||
UserRow,
|
||||
CashReceipt,
|
||||
CashReceiptInput,
|
||||
CompanyHeader,
|
||||
} from "./types";
|
||||
|
||||
// Resolve the API origin at runtime, not build time — so one built image serves
|
||||
@@ -1463,6 +1466,40 @@ export function statementPageUrl(documentId: string): string {
|
||||
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ EFE cash receipt book */
|
||||
|
||||
export function getNextCashFolio(): Promise<{ folio: number }> {
|
||||
return apiFetch("/cash-receipts/next-folio");
|
||||
}
|
||||
|
||||
export function listCashReceipts(q: { query?: string; page?: number } = {}): Promise<{
|
||||
items: CashReceipt[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (q.query) params.set("query", q.query);
|
||||
if (q.page) params.set("page", String(q.page));
|
||||
return apiFetch(`/cash-receipts?${params}`);
|
||||
}
|
||||
|
||||
export function getCashReceipt(folio: number): Promise<CashReceipt & { company: CompanyHeader }> {
|
||||
return apiFetch(`/cash-receipts/${folio}`);
|
||||
}
|
||||
|
||||
export function createCashReceipt(input: CashReceiptInput): Promise<CashReceipt> {
|
||||
return apiFetch("/cash-receipts", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export function setCashReceiptDeposit(id: string, depositedAt?: string): Promise<CashReceipt> {
|
||||
return apiFetch(`/cash-receipts/${id}/deposit`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ depositedAt }),
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------- Policy OCR (GMX / ANA) */
|
||||
|
||||
export function getPolicyOcrStatus(): Promise<{
|
||||
|
||||
@@ -1681,3 +1681,45 @@ export interface PolicyOcrConfirmResult {
|
||||
policies: string[];
|
||||
postedTransactions: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ EFE cash receipt book */
|
||||
|
||||
export interface CashReceipt {
|
||||
id: string;
|
||||
folio: number;
|
||||
receivedAt: string;
|
||||
amount: string;
|
||||
currency: Currency;
|
||||
exchangeRate: string | null;
|
||||
concept: string;
|
||||
depositedAt: string | null;
|
||||
createdAt: string;
|
||||
customer: { id: string; name: string; folderNumber: number | null };
|
||||
preparedBy: { name: string };
|
||||
/** The C<folio> credit. Voiding it voids the receipt. */
|
||||
transaction: { id: string; voidedAt: string | null; amount: string; currency: Currency };
|
||||
}
|
||||
|
||||
/** Office details the printed receipt is headed with. */
|
||||
export interface CompanyHeader {
|
||||
name: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string;
|
||||
cityState: string;
|
||||
mailingAddress: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
taxId: string;
|
||||
website: string;
|
||||
}
|
||||
|
||||
export interface CashReceiptInput {
|
||||
customerId: string;
|
||||
folio?: number;
|
||||
receivedAt: string;
|
||||
amount: number;
|
||||
currency: Currency;
|
||||
exchangeRate?: number;
|
||||
concept: string;
|
||||
depositedAt?: string;
|
||||
}
|
||||
Reference in new issue
Block a user