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>
261 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|