Files
jorgecuadros-platform/apps/web/src/components/MovementForm.tsx
T
rmancinasandClaude Opus 4.8 7d9f59e51b 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>
2026-07-23 17:17:47 -07:00

261 lines
7.7 KiB
TypeScript

"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>
);
}