Implements docs/RECEIPT_CAPTURE_SPEC.md §1, the legacy "Editor" replacement, on top of the single-movement capture from plan step 6. No new abilities: batching and resolving are both capturing. - outstanding (legacy NOPAGO): capture flag, ?outstanding= filter, and POST /billing/:id/resolve-outstanding (gated ledger:create, not ledger:void — resolving completes a capture rather than reversing one). Outstanding rows are excluded from every balance aggregate, matching the legacy SALDOS ULTIMO 0 query's HAVING NOPAGO = 0, but still count in the movement browser's filtered totals. - POST /billing/batch: many customers' receipts against one check, in one $transaction. Deliberately not a persisted batch entity — checkNumber is already a column and grouping by it answers every legacy by-check query. - GET /billing/by-check + a cheque-count report, replacing REPORTE CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE ALF|COUNT|NUM. Print, PDF, CSV and XLSX come free from the existing /reportes/:slug machinery. - Web: /estado-cuenta/lote (the Editor screen, with live reconciliation against the physical check amount), an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog, plus a top-level "Captura" nav entry. Integration seam for the OCR auto-capture module (spec §2), which is required to post through createBatch rather than writing Transaction rows itself: items[i] maps to lines[i] so postedTransactionId can be zipped back on; opts.refs[i] stamps captureRef with a duplicate-post guard that a voided row deliberately does not block; opts.source is service-level only, so an HTTP client cannot label hand-keyed rows as machine-captured. captureSource/captureRef are nullable so the 40,136 migrated rows stay NULL rather than being mislabelled. Fixes two pre-existing bugs found while building this: - statement() filtered legacySourceTable with `notIn`, which compiles to SQL NOT IN — and `NULL NOT IN (...)` is NULL, so every app-captured movement was invisible on the customer statement (438 rows in the movement browser vs 392 on the statement) while showing everywhere else. This would have made the whole capture feature look broken. - The balances count query omitted the void filter its own page query applied, so the total disagreed with the rows. Nav highlighting now resolves by longest match; the previous first-startsWith logic lit up both the parent and any nested entry. Verified end-to-end against the dev DB, API and browser; all test rows removed afterwards. Also corrects RESUME.md, which documented the dev ports as :3001/:3000 — they are :4501/:4500, from the env files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
292 lines
9.0 KiB
TypeScript
292 lines
9.0 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 [outstanding, setOutstanding] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// "Sin fondos" is a per-service *charge* concept: the office recorded a
|
|
// utility bill it couldn't cover. It never applies to a credit (a payment
|
|
// that arrived is, by definition, funded) or to the insurance/trust lines.
|
|
const canBeOutstanding = domain === "UTILITY" && direction === "charge";
|
|
|
|
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),
|
|
// Guarded by canBeOutstanding so a stale checkbox can't ride along after
|
|
// the user switches the row to a credit or another business line.
|
|
outstanding: canBeOutstanding && outstanding ? true : undefined,
|
|
};
|
|
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>
|
|
|
|
{canBeOutstanding && (
|
|
<label
|
|
className="field"
|
|
style={{ marginTop: 16, flexDirection: "row", alignItems: "center", gap: 10 }}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={outstanding}
|
|
onChange={(e) => setOutstanding(e.target.checked)}
|
|
/>
|
|
<span>
|
|
<span className="field-label" style={{ display: "block" }}>
|
|
Sin fondos (pendiente de pago)
|
|
</span>
|
|
<span className="muted" style={{ fontSize: 13 }}>
|
|
El cargo se registra pero no afecta el saldo del cliente hasta
|
|
que se resuelva con un cheque.
|
|
</span>
|
|
</span>
|
|
</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>
|
|
);
|
|
}
|