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:
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import { createMovement } from "@/lib/api";
|
||||
import type {
|
||||
CreateMovementInput,
|
||||
Currency,
|
||||
Facet,
|
||||
LedgerCurrency,
|
||||
TransactionDomain,
|
||||
} from "@/lib/types";
|
||||
|
||||
const DOMAINS: { key: TransactionDomain; label: string }[] = [
|
||||
{ key: "UTILITY", label: "Servicios" },
|
||||
{ key: "INSURANCE", label: "Seguros" },
|
||||
{ key: "TRUST", label: "Fideicomiso" },
|
||||
];
|
||||
|
||||
type Direction = "charge" | "credit";
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function s(v: string): string | undefined {
|
||||
const t = v.trim();
|
||||
return t === "" ? undefined : t;
|
||||
}
|
||||
|
||||
/** Capture form for one ledger movement. `defaultCustomer` pre-fills the picker
|
||||
* when opening from a customer's statement. `concepts` is the list of
|
||||
* transaction-type facets from the billing module. */
|
||||
export function MovementForm({
|
||||
concepts,
|
||||
defaultCurrency,
|
||||
defaultCustomer,
|
||||
defaultDomain,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
concepts: Facet[];
|
||||
defaultCurrency?: LedgerCurrency;
|
||||
defaultCustomer?: { id: string; name: string };
|
||||
defaultDomain?: TransactionDomain;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [customerId, setCustomerId] = useState(defaultCustomer?.id ?? "");
|
||||
const [customerName, setCustomerName] = useState(defaultCustomer?.name ?? "");
|
||||
const [domain, setDomain] = useState<TransactionDomain>(
|
||||
defaultDomain ?? "UTILITY",
|
||||
);
|
||||
const [direction, setDirection] = useState<Direction>("charge");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [transactionDate, setTransactionDate] = useState(today());
|
||||
const [currency, setCurrency] = useState<LedgerCurrency>(
|
||||
defaultCurrency ?? "MXN",
|
||||
);
|
||||
const [typeId, setTypeId] = useState("");
|
||||
const [period, setPeriod] = useState("");
|
||||
const [reference, setReference] = useState("");
|
||||
const [checkNumber, setCheckNumber] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!customerId) {
|
||||
setError("Selecciona un cliente.");
|
||||
return;
|
||||
}
|
||||
const abs = Number(amount);
|
||||
if (!Number.isFinite(abs) || abs === 0) {
|
||||
setError("El monto debe ser un número distinto de cero.");
|
||||
return;
|
||||
}
|
||||
const signed = direction === "charge" ? -Math.abs(abs) : Math.abs(abs);
|
||||
const payload: CreateMovementInput = {
|
||||
customerId,
|
||||
domain,
|
||||
amount: signed,
|
||||
transactionDate,
|
||||
currency: currency as Currency,
|
||||
typeId: s(typeId),
|
||||
period: s(period),
|
||||
reference: s(reference),
|
||||
checkNumber: s(checkNumber),
|
||||
message: s(message),
|
||||
};
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createMovement(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 }}>Cliente</h2>
|
||||
<CustomerPicker
|
||||
value={customerId}
|
||||
valueName={customerId ? customerName : undefined}
|
||||
onPick={(id, name) => {
|
||||
setCustomerId(id);
|
||||
setCustomerName(name);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Movimiento</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Línea de negocio" required>
|
||||
<select
|
||||
className="select"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
|
||||
>
|
||||
{DOMAINS.map((d) => (
|
||||
<option key={d.key} value={d.key}>
|
||||
{d.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Fecha" required>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
required
|
||||
value={transactionDate}
|
||||
onChange={(e) => setTransactionDate(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Tipo" required>
|
||||
<select
|
||||
className="select"
|
||||
value={direction}
|
||||
onChange={(e) => setDirection(e.target.value as Direction)}
|
||||
>
|
||||
<option value="charge">Cargo</option>
|
||||
<option value="credit">Abono</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Monto" required>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
required
|
||||
min="0"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Moneda" required>
|
||||
<select
|
||||
className="select"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
|
||||
>
|
||||
<option value="MXN">Pesos (MXN)</option>
|
||||
<option value="USD">Dólares (USD)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Concepto">
|
||||
<select
|
||||
className="select"
|
||||
value={typeId}
|
||||
onChange={(e) => setTypeId(e.target.value)}
|
||||
>
|
||||
<option value="">(sin concepto)</option>
|
||||
{concepts.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Detalles</h2>
|
||||
<div className="form-grid">
|
||||
<Field label="Periodo">
|
||||
<input
|
||||
className="input"
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value)}
|
||||
placeholder="Ej. 2025-01"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Referencia">
|
||||
<input
|
||||
className="input"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Número de cheque">
|
||||
<input
|
||||
className="input"
|
||||
value={checkNumber}
|
||||
onChange={(e) => setCheckNumber(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<label className="field" style={{ marginTop: 16 }}>
|
||||
<span className="field-label">Mensaje</span>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(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 Field({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
{label}
|
||||
{required && <span aria-hidden> *</span>}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user