Found driving /clientes/alta and /efectivo in the browser: the default date was UTC's (tomorrow, every evening), selects lacked the input style, the service rows stretched one input per line, and the printed receipt showed a bare $ with no currency. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
293 lines
9.6 KiB
TypeScript
293 lines
9.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|
||
|
||
// The office's calendar day, not UTC's — in the evening UTC is already tomorrow.
|
||
const today = () => new Date().toLocaleDateString("en-CA");
|
||
|
||
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="input 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>
|
||
);
|
||
}
|