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