feat(billing,bank): capture + void web UI (plan phase 5 web)

Completes phase 5 — the ledger and chequera pages get the append+void
UI on top of the phase-5 API.

Web:
- Shared MovementForm (customer picker + línea + cargo/abono sign + amount
  + moneda + concepto facet + periodo/referencia/cheque/mensaje). Used by
  both /estado-cuenta (cross-customer, picker) and /estado-cuenta/[id]
  (customer prefilled).
- /estado-cuenta and /estado-cuenta/[id]: "Capturar movimiento" toggle
  gated ledger:create; per-row "Anular" gated ledger:void; voided rows
  struck-through. Save/void refresh the list + stats.
- /banco: inline BankCaptureForm (ingreso/egreso sign, cheque, operado,
  transferencia, monto en letras) gated bank:create; per-row "Anular"
  gated bank:void; voided rows struck-through.
- api.ts: createMovement/voidMovement, createBankMovement/voidBankMovement;
  CreateMovementInput/CreateBankMovementInput types; `voided` on the
  movement/statement/bank list items.

Also: lookups.controller.ts now audit-logs provider/policy-type/adjuster
create/update/delete (parity with the other write controllers).

API + web compile clean. This is the last piece of the feat/crud-rbac
branch — all five sections plus users are now full CRUD with role gating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 17:17:47 -07:00
co-authored by Claude Opus 4.8
parent 548eeb5798
commit 7d9f59e51b
7 changed files with 897 additions and 36 deletions
+282 -6
View File
@@ -3,11 +3,14 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import {
createBankMovement,
getBankFacets,
getBankStats,
getBankSummary,
listBankMovements,
voidBankMovement,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import {
bankDirectionLabel,
bankSourceLabel,
@@ -27,6 +30,7 @@ import type {
BankStats,
BankSummary,
BankTotals,
CreateBankMovementInput,
} from "@/lib/types";
/**
@@ -77,6 +81,8 @@ export default function BancoPage() {
}
function BankBrowser() {
const canCapture = useCan("bank:create");
const canVoid = useCan("bank:void");
const [stats, setStats] = useState<BankStats | null>(null);
const [facets, setFacets] = useState<BankFacets | null>(null);
const [view, setView] = useState<View>("movimientos");
@@ -93,6 +99,7 @@ function BankBrowser() {
const [summaryYear, setSummaryYear] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [captureOpen, setCaptureOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
@@ -237,8 +244,28 @@ function BankBrowser() {
</button>
))}
</div>
{view === "movimientos" && canCapture && (
<button
type="button"
className="btn btn-primary"
onClick={() => setCaptureOpen((v) => !v)}
>
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
</button>
)}
</div>
{view === "movimientos" && captureOpen && (
<BankCaptureForm
onSaved={() => {
setCaptureOpen(false);
runSearch(movements?.page ?? 1);
getBankStats().then(setStats).catch(() => setStats(null));
}}
onCancel={() => setCaptureOpen(false)}
/>
)}
{view === "movimientos" && (
<div className="filter-row">
<label className="filter-field">
@@ -383,11 +410,26 @@ function BankBrowser() {
<th>Beneficiario / concepto</th>
<th>Origen</th>
<th className="num">Monto</th>
{canVoid && (
<th style={{ width: 1, whiteSpace: "nowrap" }}>
Acciones
</th>
)}
</tr>
</thead>
<tbody>
{movements?.items.map((m) => (
<BankRow key={m.id} m={m} />
<BankRow
key={m.id}
m={m}
canVoid={canVoid}
onVoided={() => {
runSearch(movements?.page ?? 1);
getBankStats()
.then(setStats)
.catch(() => setStats(null));
}}
/>
))}
</tbody>
</table>
@@ -524,17 +566,44 @@ function FilteredTotals({ totals }: { totals: BankTotals }) {
);
}
function BankRow({ m }: { m: BankListItem }) {
function BankRow({
m,
canVoid,
onVoided,
}: {
m: BankListItem;
canVoid: boolean;
onVoided: () => void;
}) {
const [busy, setBusy] = useState(false);
async function doVoid() {
if (
!window.confirm(
"¿Anular este movimiento? Quedará tachado y no contará en los totales.",
)
)
return;
setBusy(true);
try {
await voidBankMovement(m.id);
onVoided();
} catch (e) {
window.alert(
(e as Error)?.message ?? "No se pudo anular el movimiento.",
);
setBusy(false);
}
}
return (
<tr>
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(m.transactionDate)}
</td>
<td className="tx-ref">
{m.reference || "—"}
{!m.cleared && (
<div className="tx-concept">Sin operar</div>
)}
{!m.cleared && <div className="tx-concept">Sin operar</div>}
</td>
<td>
{m.concept || <span className="muted">Sin concepto</span>}
@@ -547,6 +616,21 @@ function BankRow({ m }: { m: BankListItem }) {
</span>
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
</td>
{canVoid && (
<td style={{ whiteSpace: "nowrap" }}>
{!m.voided && (
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={doVoid}
disabled={busy}
>
{busy ? "Anulando…" : "Anular"}
</button>
)}
</td>
)}
</tr>
);
}
@@ -687,6 +771,198 @@ function SummaryView({
);
}
/** Inline capture form for a single chequera movement. Single currency (MXN);
* sign convention: positive = ingreso, negative = egreso. Booked rows are
* never edited — fix mistakes with voidBankMovement + a fresh capture. */
function BankCaptureForm({
onSaved,
onCancel,
}: {
onSaved: () => void;
onCancel: () => void;
}) {
const [direction, setDirection] = useState<BankDirection>("expense");
const [amount, setAmount] = useState("");
const [transactionDate, setTransactionDate] = useState(
new Date().toISOString().slice(0, 10),
);
const [concept, setConcept] = useState("");
const [reference, setReference] = useState("");
const [transactionType, setTransactionType] = useState("");
const [cleared, setCleared] = useState(true);
const [transferred, setTransferred] = useState(false);
const [notes, setNotes] = useState("");
const [amountInWords, setAmountInWords] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function s(v: string): string | undefined {
const t = v.trim();
return t === "" ? undefined : t;
}
async function submit(e: React.FormEvent) {
e.preventDefault();
const abs = Number(amount);
if (!Number.isFinite(abs) || abs <= 0) {
setError("El monto debe ser un número mayor a cero.");
return;
}
const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs);
const payload: CreateBankMovementInput = {
amount: signed,
transactionDate,
concept: s(concept),
reference: s(reference),
transactionType: s(transactionType),
cleared,
transferred,
notes: s(notes),
amountInWords: s(amountInWords),
};
setSaving(true);
setError(null);
try {
await createBankMovement(payload);
onSaved();
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar el movimiento.");
setSaving(false);
}
}
return (
<form onSubmit={submit}>
{error && <div className="state-box state-error">{error}</div>}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
Capturar movimiento de chequera
</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">
Tipo <span aria-hidden>*</span>
</span>
<select
className="select"
value={direction}
onChange={(e) => setDirection(e.target.value as BankDirection)}
>
<option value="income">Ingreso</option>
<option value="expense">Egreso</option>
</select>
</label>
<label className="field">
<span className="field-label">
Fecha <span aria-hidden>*</span>
</span>
<input
className="input"
type="date"
required
value={transactionDate}
onChange={(e) => setTransactionDate(e.target.value)}
/>
</label>
<label className="field">
<span className="field-label">
Monto (MXN) <span aria-hidden>*</span>
</span>
<input
className="input"
type="number"
step="0.01"
required
min="0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
/>
</label>
<label className="field">
<span className="field-label">Concepto</span>
<input
className="input"
value={concept}
onChange={(e) => setConcept(e.target.value)}
placeholder="Beneficiario o motivo"
/>
</label>
<label className="field">
<span className="field-label">Referencia / cheque</span>
<input
className="input"
value={reference}
onChange={(e) => setReference(e.target.value)}
/>
</label>
<label className="field">
<span className="field-label">Tipo en origen</span>
<input
className="input"
value={transactionType}
onChange={(e) => setTransactionType(e.target.value)}
placeholder="INGRESO / EGRESO"
/>
</label>
<label className="field">
<span className="field-label">Monto en letras</span>
<input
className="input"
value={amountInWords}
onChange={(e) => setAmountInWords(e.target.value)}
placeholder="Ej. CIENTO CINCUENTA MIL PESOS 00/100"
/>
</label>
<label
className="field"
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<input
type="checkbox"
checked={cleared}
onChange={(e) => setCleared(e.target.checked)}
/>
<span className="field-label" style={{ margin: 0 }}>
Operado por el banco
</span>
</label>
<label
className="field"
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<input
type="checkbox"
checked={transferred}
onChange={(e) => setTransferred(e.target.checked)}
/>
<span className="field-label" style={{ margin: 0 }}>
Transferencia
</span>
</label>
</div>
<label className="field" style={{ marginTop: 16 }}>
<span className="field-label">Notas</span>
<textarea
className="input"
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Guardando…" : "Capturar movimiento"}
</button>
<button type="button" className="btn btn-outline" onClick={onCancel}>
Cancelar
</button>
</div>
</form>
);
}
function Pager({
page,
pageCount,
+120 -8
View File
@@ -3,7 +3,13 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { getStatement } from "@/lib/api";
import { MovementForm } from "@/components/MovementForm";
import {
getBillingFacets,
getStatement,
voidMovement,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import {
balancePhrase,
balanceTone,
@@ -17,6 +23,7 @@ import {
txTypeLabel,
} from "@/lib/labels";
import type {
BillingFacets,
LedgerCurrency,
Statement,
StatementMovement,
@@ -48,14 +55,18 @@ export default function EstadoCuentaDetailPage({
}
function StatementView({ id }: { id: string }) {
const canCapture = useCan("ledger:create");
const canVoid = useCan("ledger:void");
const [data, setData] = useState<Statement | null>(null);
const [facets, setFacets] = useState<BillingFacets | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [captureOpen, setCaptureOpen] = useState(false);
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
const [domain, setDomain] = useState<TransactionDomain | "">("");
useEffect(() => {
function reload() {
let alive = true;
setLoading(true);
setError(null);
@@ -63,9 +74,10 @@ function StatementView({ id }: { id: string }) {
.then((d) => {
if (!alive) return;
setData(d);
// Default to the currency the customer actually moves the most in.
// Default to the currency the customer actually moves the most in;
// preserve a previously-chosen currency across reloads.
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
setCurrency(busiest?.currency ?? "MXN");
setCurrency((prev) => prev ?? busiest?.currency ?? "MXN");
setLoading(false);
})
.catch((e) => {
@@ -80,6 +92,13 @@ function StatementView({ id }: { id: string }) {
return () => {
alive = false;
};
}
useEffect(() => {
const cleanup = reload();
getBillingFacets().then(setFacets).catch(() => setFacets(null));
return cleanup;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const movements = useMemo(() => {
@@ -178,8 +197,35 @@ function StatementView({ id }: { id: string }) {
title="Movimientos"
count={movements.length}
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
right={
canCapture ? (
<button
type="button"
className="btn btn-primary"
onClick={() => setCaptureOpen((v) => !v)}
>
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
</button>
) : undefined
}
/>
{captureOpen && (
<MovementForm
concepts={facets?.types ?? []}
defaultCurrency={currency ?? "MXN"}
defaultCustomer={{
id: data.customer.id,
name: data.customer.name,
}}
onSaved={() => {
setCaptureOpen(false);
reload();
}}
onCancel={() => setCaptureOpen(false)}
/>
)}
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Moneda</span>
@@ -228,11 +274,21 @@ function StatementView({ id }: { id: string }) {
<th>Referencia</th>
<th className="num">Cargo / Abono</th>
<th className="num">Saldo</th>
{canVoid && (
<th style={{ width: 1, whiteSpace: "nowrap" }}>
Acciones
</th>
)}
</tr>
</thead>
<tbody>
{movements.map((m) => (
<StatementRow key={m.id} m={m} />
<StatementRow
key={m.id}
m={m}
canVoid={canVoid}
onVoided={reload}
/>
))}
</tbody>
</table>
@@ -425,10 +481,39 @@ function ConceptosSection({
);
}
function StatementRow({ m }: { m: StatementMovement }) {
function StatementRow({
m,
canVoid,
onVoided,
}: {
m: StatementMovement;
canVoid: boolean;
onVoided: () => void;
}) {
const concept = m.message || m.period || null;
const [busy, setBusy] = useState(false);
async function doVoid() {
if (
!window.confirm(
"¿Anular este movimiento? Quedará tachado y no contará en los totales.",
)
)
return;
setBusy(true);
try {
await voidMovement(m.id);
onVoided();
} catch (e) {
window.alert(
(e as Error)?.message ?? "No se pudo anular el movimiento.",
);
setBusy(false);
}
}
return (
<tr>
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(m.transactionDate)}
</td>
@@ -455,6 +540,21 @@ function StatementRow({ m }: { m: StatementMovement }) {
{formatMoney(m.balanceAfter, m.currency)}
</span>
</td>
{canVoid && (
<td style={{ whiteSpace: "nowrap" }}>
{!m.voided && (
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={doVoid}
disabled={busy}
>
{busy ? "Anulando…" : "Anular"}
</button>
)}
</td>
)}
</tr>
);
}
@@ -464,14 +564,23 @@ function SectionHead({
title,
count,
countSuffix,
right,
}: {
rule: string;
title: string;
count?: number;
countSuffix?: string;
right?: React.ReactNode;
}) {
return (
<div className="section-head">
<div
className="section-head"
style={
right
? { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }
: undefined
}
>
<span className={`section-rule ${rule}`} aria-hidden />
<h2 className="section-title">{title}</h2>
{count != null && (
@@ -479,6 +588,9 @@ function SectionHead({
{formatNumber(count)} {countSuffix ?? ""}
</span>
)}
{right && (
<div style={{ marginLeft: "auto" }}>{right}</div>
)}
</div>
);
}
+88 -3
View File
@@ -3,12 +3,15 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { MovementForm } from "@/components/MovementForm";
import {
getBillingFacets,
getBillingStats,
listBalances,
listMovements,
voidMovement,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import {
balancePhrase,
balanceTone,
@@ -95,6 +98,8 @@ export default function EstadoCuentaPage() {
}
function BillingBrowser() {
const canCapture = useCan("ledger:create");
const canVoid = useCan("ledger:void");
const [stats, setStats] = useState<BillingStats | null>(null);
const [facets, setFacets] = useState<BillingFacets | null>(null);
const [view, setView] = useState<View>("saldos");
@@ -119,6 +124,7 @@ function BillingBrowser() {
const [movements, setMovements] = useState<MovementListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [captureOpen, setCaptureOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
@@ -287,8 +293,38 @@ function BillingBrowser() {
</button>
))}
</div>
{view === "movimientos" && canCapture && (
<button
type="button"
className="btn btn-primary"
onClick={() => setCaptureOpen((v) => !v)}
>
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
</button>
)}
</div>
{view === "movimientos" && captureOpen && (
<section className="section">
<div className="section-head">
<span className="section-rule cuenta" aria-hidden />
<h2 className="section-title">Capturar movimiento</h2>
</div>
<MovementForm
concepts={facets?.types ?? []}
defaultCurrency={currency}
onSaved={() => {
setCaptureOpen(false);
runSearch(movements?.page ?? 1);
getBillingStats()
.then(setStats)
.catch(() => setStats(null));
}}
onCancel={() => setCaptureOpen(false)}
/>
</section>
)}
<div className="filter-row">
<label className="filter-field">
<span className="filter-label">Moneda</span>
@@ -506,11 +542,22 @@ function BillingBrowser() {
<th>Concepto</th>
<th>Referencia</th>
<th className="num">Monto</th>
{canVoid && <th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>}
</tr>
</thead>
<tbody>
{movements?.items.map((m) => (
<MovementRow key={m.id} m={m} />
<MovementRow
key={m.id}
m={m}
canVoid={canVoid}
onVoided={() => {
runSearch(movements?.page ?? 1);
getBillingStats()
.then(setStats)
.catch(() => setStats(null));
}}
/>
))}
</tbody>
</table>
@@ -739,9 +786,32 @@ function BalanceRow({
);
}
function MovementRow({ m }: { m: MovementListItem }) {
function MovementRow({
m,
canVoid,
onVoided,
}: {
m: MovementListItem;
canVoid: boolean;
onVoided: () => void;
}) {
const [busy, setBusy] = useState(false);
async function doVoid() {
if (!window.confirm("¿Anular este movimiento? Quedará tachado y no contará en los totales."))
return;
setBusy(true);
try {
await voidMovement(m.id);
onVoided();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo anular el movimiento.");
setBusy(false);
}
}
return (
<tr>
<tr style={m.voided ? { textDecoration: "line-through", opacity: 0.55 } : undefined}>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(m.transactionDate)}
</td>
@@ -776,6 +846,21 @@ function MovementRow({ m }: { m: MovementListItem }) {
{m.currency} · {directionLabel(m.direction)}
</div>
</td>
{canVoid && (
<td style={{ whiteSpace: "nowrap" }}>
{!m.voided && (
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={doVoid}
disabled={busy}
>
{busy ? "Anulando…" : "Anular"}
</button>
)}
</td>
)}
</tr>
);
}