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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user