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,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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user