- Volver
+ Volver a captura
diff --git a/apps/web/src/app/recibos/page.tsx b/apps/web/src/app/recibos/page.tsx
index 0f6b605..6c806fc 100644
--- a/apps/web/src/app/recibos/page.tsx
+++ b/apps/web/src/app/recibos/page.tsx
@@ -1,270 +1,15 @@
-"use client";
-
-import { useCallback, useEffect, useState } from "react";
-import Link from "next/link";
import { AppShell } from "@/components/AppShell";
-import {
- getStatementStatus,
- listStatementBatches,
- uploadStatementBatch,
-} from "@/lib/api";
-import { useCan } from "@/lib/abilities";
-import { formatDate, SERVICE_KIND_LABELS, serviceKindLabel } from "@/lib/labels";
-import type { ServiceKind, StatementBatch, StatementBatchStatus } from "@/lib/types";
+import { Captura } from "@/components/Captura";
/**
- * Statement OCR intake (docs/RECEIPT_CAPTURE_SPEC.md §2).
- *
- * Each utility company mails 300+ paper bills a month, one per customer, which
- * staff otherwise key in by hand through the "Captura" screen. Here they scan
- * the stack, and the machine proposes customer + amount for every page; a
- * human still confirms before anything reaches the ledger.
- *
- * One batch = one service kind, because the matcher is scoped per kind: a
- * water account number and a phone number are compared against different
- * columns, and mixing them in one upload is how a bill gets posted to the
- * wrong customer.
+ * Same capture screen as `/estado-cuenta/lote`, opened on the automatic
+ * (scanned recibos + OCR) mode. Kept as its own route so links from a batch
+ * review page and older bookmarks land on the right tab.
*/
-
-/** The kinds the parsers actually recognise today. */
-const SUPPORTED: ServiceKind[] = ["ELECTRIC", "WATER", "TELEPHONE"];
-/** Uploadable, but every page will land in review until a parser learns it. */
-const OTHER_KINDS: ServiceKind[] = ["GAS", "PROPERTY_TAX", "FEDERAL_ZONE", "CABLE"];
-
-const STATUS_LABEL: Record = {
- UPLOADED: "Recibido",
- PROCESSING: "Procesando…",
- READY_FOR_REVIEW: "Listo para revisar",
- COMPLETED: "Registrado",
- FAILED: "Falló",
-};
-
export default function RecibosPage() {
return (
-
+
);
}
-
-function Recibos() {
- const canIngest = useCan("statement:ingest");
- const [batches, setBatches] = useState([]);
- const [ocrAvailable, setOcrAvailable] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- const load = useCallback(async () => {
- try {
- const [list, status] = await Promise.all([
- listStatementBatches(),
- getStatementStatus(),
- ]);
- setBatches(list.items);
- setOcrAvailable(status.ocrAvailable);
- setError(null);
- } catch (e) {
- setError((e as Error)?.message ?? "No se pudieron cargar los lotes.");
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- void load();
- }, [load]);
-
- // A batch of 300 pages takes minutes to OCR, so the list refreshes itself
- // while anything is still working rather than making staff reload.
- const working = batches.some(
- (b) => b.status === "PROCESSING" || b.status === "UPLOADED",
- );
- useEffect(() => {
- if (!working) return;
- const t = setInterval(() => void load(), 4000);
- return () => clearInterval(t);
- }, [working, load]);
-
- return (
-
-
-
-
Recibos (OCR)
-
- Escanea los recibos del mes y el sistema propone cliente e importe
- para cada página. Nada se registra sin tu confirmación.
-
-
-
-
- {ocrAvailable === false && (
-
- Este servidor no tiene OCR instalado, así que no se pueden procesar
- recibos. El resto del sistema funciona con normalidad.
-
- Todavía no hay lectura automática para{" "}
- {SERVICE_KIND_LABELS[serviceKind] ?? serviceKind}: cada página quedará
- para revisión manual. Al confirmarlas se guarda el número de cuenta,
- así que los recibos del mes siguiente sí se reconocerán solos.
-
- )}
-
-
- Un lote es de un solo servicio. Cada página del PDF se trata como un
- recibo distinto, salvo que el proveedor imprima varias hojas por cliente.
-
-
- );
-}
diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx
index 40f18fe..575c243 100644
--- a/apps/web/src/components/AppShell.tsx
+++ b/apps/web/src/components/AppShell.tsx
@@ -24,7 +24,15 @@ import type { AuthUser, Ability } from "@/lib/types";
* abilities. Used by every authenticated page.
*/
-type NavLink = { href: string; label: string; ability?: Ability; exact?: boolean };
+type NavLink = {
+ href: string;
+ label: string;
+ ability?: Ability;
+ exact?: boolean;
+ /** Extra path prefixes that belong to this entry (e.g. a second route into
+ * the same screen), so they highlight it instead of nothing. */
+ aliases?: string[];
+};
type NavEntry =
| ({ kind: "link" } & NavLink)
| { kind: "group"; label: string; items: NavLink[] };
@@ -45,11 +53,16 @@ const NAV: NavEntry[] = [
label: "Cobranza",
items: [
// Daily data-entry screen (the legacy "Editor"). Hidden from VIEWER, who
- // can't capture anyway — the page itself also refuses.
- { href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" },
- // Same daily job as "Captura", entered from a stack of scanned bills
- // instead of a keyboard.
- { href: "/recibos", label: "Recibos (OCR)", ability: "statement:ingest" },
+ // can't capture anyway — the page itself also refuses. Both capture modes
+ // live behind this one entry: keying receipts by hand, and scanning a
+ // stack of bills for OCR (the `/recibos` route opens the same screen on
+ // its automatic tab).
+ {
+ href: "/estado-cuenta/lote",
+ label: "Captura",
+ ability: "ledger:create",
+ aliases: ["/recibos"],
+ },
{ href: "/estado-cuenta", label: "Estado de cuenta" },
{ href: "/banco", label: "Chequera" },
],
@@ -101,9 +114,11 @@ function activeHref(pathname: string | null): string | null {
if (!pathname) return null;
let best: string | null = null;
for (const item of NAV_LINKS) {
+ const under = (href: string) =>
+ pathname === href || pathname.startsWith(`${href}/`);
const match = item.exact
? pathname === item.href
- : pathname === item.href || pathname.startsWith(`${item.href}/`);
+ : under(item.href) || (item.aliases?.some(under) ?? false);
if (match && (best === null || item.href.length > best.length)) {
best = item.href;
}
diff --git a/apps/web/src/components/Captura.tsx b/apps/web/src/components/Captura.tsx
new file mode 100644
index 0000000..c925d23
--- /dev/null
+++ b/apps/web/src/components/Captura.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { ManualCheckCapture } from "@/components/ManualCheckCapture";
+import { StatementIntake } from "@/components/StatementIntake";
+import { useCan } from "@/lib/abilities";
+
+/**
+ * The daily capture screen (the legacy "Editor"), with two ways in:
+ *
+ * - **manual** — key each customer's receipt against one check by hand.
+ * - **auto** — scan the stack of paper bills and let OCR propose customer and
+ * amount for every page, which a human still confirms.
+ *
+ * Both end in the same place: charges on the customer's ledger, posted against
+ * one check. They are modes of one screen rather than two menu entries because
+ * it is one job — staff pick the mode by what's on the desk that morning, a
+ * stack of bills or a keyboard.
+ *
+ * `/estado-cuenta/lote` opens on manual, `/recibos` opens on auto; both render
+ * this component, so an old bookmark still lands on the right tab.
+ */
+
+export type CaptureMode = "manual" | "auto";
+
+const MODE_HINT: Record = {
+ manual:
+ "Captura los recibos de varios clientes contra un mismo cheque y concilia el total antes de guardar.",
+ auto: "Escanea los recibos del mes y el sistema propone cliente e importe para cada página. Nada se registra sin tu confirmación.",
+};
+
+export function Captura({ initialMode = "manual" }: { initialMode?: CaptureMode }) {
+ const canCapture = useCan("ledger:create");
+ const canIngest = useCan("statement:ingest");
+
+ // Gating is cosmetic (the API enforces every write), but a user who only has
+ // one of the two abilities should land on the mode they can actually use.
+ const modes: { key: CaptureMode; label: string }[] = [
+ ...(canCapture ? [{ key: "manual" as const, label: "Captura manual" }] : []),
+ ...(canIngest
+ ? [{ key: "auto" as const, label: "Captura automática (OCR)" }]
+ : []),
+ ];
+
+ const [mode, setMode] = useState(
+ modes.some((m) => m.key === initialMode) ? initialMode : (modes[0]?.key ?? "manual"),
+ );
+
+ if (modes.length === 0) {
+ return (
+
+ No tienes permiso para capturar movimientos.
+
+ );
+ }
+
+ return (
+ <>
+
+
+
Captura
+
{MODE_HINT[mode]}
+
+
+ Volver a estado de cuenta
+
+
+
+ {modes.length > 1 && (
+
+ {modes.map((m) => (
+
+ ))}
+
+ )}
+
+ {mode === "manual" ? : }
+ >
+ );
+}
diff --git a/apps/web/src/components/ManualCheckCapture.tsx b/apps/web/src/components/ManualCheckCapture.tsx
new file mode 100644
index 0000000..5e2944c
--- /dev/null
+++ b/apps/web/src/components/ManualCheckCapture.tsx
@@ -0,0 +1,526 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import Link from "next/link";
+import { CustomerPicker } from "@/components/CustomerPicker";
+import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api";
+import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels";
+import type {
+ BatchCreateInput,
+ BillingFacets,
+ ByCheckResponse,
+ Currency,
+ LedgerCurrency,
+ TransactionDomain,
+} from "@/lib/types";
+
+/**
+ * Batch capture by check — the "Editor" screen from the legacy system
+ * (docs/RECEIPT_CAPTURE_SPEC.md §1.2), and the manual half of the Captura
+ * screen (see `Captura`).
+ *
+ * Staff key many customers' receipts against ONE physical check before cutting
+ * it, then check that the captured total matches the check's amount. That
+ * reconciliation is the whole point, so the running total is the most prominent
+ * thing on the page and an optional "importe del cheque" field turns it into a
+ * live difference.
+ *
+ * No batch entity is persisted: `checkNumber` is a plain column, and grouping
+ * by it answers every by-check question (see the "Reporte por cheque" report).
+ */
+
+const DOMAINS: { key: TransactionDomain; label: string }[] = [
+ { key: "UTILITY", label: "Servicios" },
+ { key: "INSURANCE", label: "Seguros" },
+ { key: "TRUST", label: "Fideicomiso" },
+];
+
+interface Line {
+ /** Local row key — lines have no server identity until the batch posts. */
+ key: number;
+ customerId: string;
+ customerName: string;
+ amount: string;
+ reference: string;
+ period: string;
+ outstanding: boolean;
+}
+
+function blankLine(key: number): Line {
+ return {
+ key,
+ customerId: "",
+ customerName: "",
+ amount: "",
+ reference: "",
+ period: "",
+ outstanding: false,
+ };
+}
+
+export function ManualCheckCapture() {
+ const [facets, setFacets] = useState(null);
+
+ // Check-level fields — shared by every line.
+ const [domain, setDomain] = useState("UTILITY");
+ const [currency, setCurrency] = useState("MXN");
+ const [typeId, setTypeId] = useState("");
+ const [checkNumber, setCheckNumber] = useState("");
+ const [transactionDate, setTransactionDate] = useState(
+ new Date().toISOString().slice(0, 10),
+ );
+ /** The physical check's amount, for reconciliation only — never submitted. */
+ const [checkAmount, setCheckAmount] = useState("");
+
+ const [lines, setLines] = useState([blankLine(1), blankLine(2), blankLine(3)]);
+ const [nextKey, setNextKey] = useState(4);
+
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [posted, setPosted] = useState(null);
+
+ useEffect(() => {
+ getBillingFacets().then(setFacets).catch(() => setFacets(null));
+ }, []);
+
+ const filled = lines.filter(
+ (l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)),
+ );
+
+ // Charges are captured as positive numbers and signed on submit, matching
+ // MovementForm — staff type what's on the bill, not a negative.
+ const total = useMemo(
+ () =>
+ filled
+ .filter((l) => !l.outstanding)
+ .reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
+ [filled],
+ );
+ const outstandingTotal = useMemo(
+ () =>
+ filled
+ .filter((l) => l.outstanding)
+ .reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
+ [filled],
+ );
+
+ const checkAmt = Number(checkAmount);
+ const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt);
+ const diff = hasCheckAmt ? checkAmt - total : 0;
+ const reconciled = hasCheckAmt && Math.abs(diff) < 0.005;
+
+ function update(key: number, patch: Partial) {
+ setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l)));
+ }
+
+ function addLine() {
+ setLines((ls) => [...ls, blankLine(nextKey)]);
+ setNextKey((k) => k + 1);
+ }
+
+ function removeLine(key: number) {
+ setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key)));
+ }
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!checkNumber.trim()) {
+ setError("Indica el número de cheque.");
+ return;
+ }
+ if (filled.length === 0) {
+ setError("Captura al menos una línea con cliente y monto.");
+ return;
+ }
+ const dupes = filled
+ .map((l) => l.customerId)
+ .filter((id, i, arr) => arr.indexOf(id) !== i);
+ if (dupes.length) {
+ const names = filled
+ .filter((l) => dupes.includes(l.customerId))
+ .map((l) => l.customerName);
+ if (
+ !window.confirm(
+ `Hay más de una línea para el mismo cliente (${[...new Set(names)].join(
+ ", ",
+ )}). ¿Continuar?`,
+ )
+ )
+ return;
+ }
+
+ const payload: BatchCreateInput = {
+ domain,
+ transactionDate,
+ checkNumber: checkNumber.trim(),
+ currency: currency as Currency,
+ typeId: typeId || undefined,
+ lines: filled.map((l) => ({
+ customerId: l.customerId,
+ // Every line of a check batch is a charge the office paid out.
+ amount: -Math.abs(Number(l.amount)),
+ reference: l.reference.trim() || undefined,
+ period: l.period.trim() || undefined,
+ outstanding: l.outstanding || undefined,
+ })),
+ };
+
+ setSaving(true);
+ setError(null);
+ try {
+ await createMovementBatch(payload);
+ // Re-read through the by-check view so the confirmation shows what's
+ // actually stored (including anything captured against this check
+ // earlier), not just what this request sent.
+ setPosted(await getByCheck(payload.checkNumber));
+ } catch (e2) {
+ setError((e2 as Error)?.message ?? "No se pudo guardar el lote.");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function reset() {
+ setPosted(null);
+ setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]);
+ setNextKey((k) => k + 3);
+ setCheckNumber("");
+ setCheckAmount("");
+ }
+
+ if (posted) {
+ return (
+ <>
+
+ {t.currency}
+
+ Total del cheque {formatMoney(t.total, t.currency)}
+
+ {formatNumber(t.count)} movimientos
+
+ ))}
+ {posted.outstandingCount > 0 && (
+
+
+ {formatNumber(posted.outstandingCount)} sin fondos (no suman al
+ total)
+
+
+ )}
+
+
+
+
+
+
+
Cliente
+
Referencia
+
Periodo
+
Estado
+
Monto
+
+
+
+ {posted.items.map((i) => (
+
+
+
+ {i.customerName}
+
+
+
{i.reference || "—"}
+
{i.period || "—"}
+
{i.outstanding ? "Sin fondos" : "Pagado"}
+
+
+ {formatMoney(i.amount, i.currency)}
+
+
+
+ ))}
+
+
+
+
+
+ Para imprimir la conciliación, usa el reporte{" "}
+
+ Reporte por cheque
+
+ .
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {error &&
{error}
}
+
+
+ >
+ );
+}
diff --git a/apps/web/src/components/StatementIntake.tsx b/apps/web/src/components/StatementIntake.tsx
new file mode 100644
index 0000000..0404fb5
--- /dev/null
+++ b/apps/web/src/components/StatementIntake.tsx
@@ -0,0 +1,272 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import {
+ getStatementStatus,
+ listStatementBatches,
+ uploadStatementBatch,
+} from "@/lib/api";
+import { useCan } from "@/lib/abilities";
+import { formatDate, SERVICE_KIND_LABELS, serviceKindLabel } from "@/lib/labels";
+import type { ServiceKind, StatementBatch, StatementBatchStatus } from "@/lib/types";
+
+/**
+ * Automatic capture — statement OCR intake (docs/RECEIPT_CAPTURE_SPEC.md §2).
+ *
+ * Each utility company mails 300+ paper bills a month, one per customer, which
+ * staff otherwise key in by hand on the manual tab of the same "Captura"
+ * screen. Here they scan the stack instead, and the machine proposes customer +
+ * amount for every page; a human still confirms before anything reaches the
+ * ledger. Same daily job, same ledger path — only the input differs, which is
+ * why it lives as a mode of Captura rather than a screen of its own.
+ *
+ * One batch = one service kind, because the matcher is scoped per kind: a
+ * water account number and a phone number are compared against different
+ * columns, and mixing them in one upload is how a bill gets posted to the
+ * wrong customer.
+ */
+
+/** The kinds the parsers actually recognise today. */
+const SUPPORTED: ServiceKind[] = ["ELECTRIC", "WATER", "TELEPHONE"];
+/** Uploadable, but every page will land in review until a parser learns it. */
+const OTHER_KINDS: ServiceKind[] = ["GAS", "PROPERTY_TAX", "FEDERAL_ZONE", "CABLE"];
+
+const STATUS_LABEL: Record = {
+ UPLOADED: "Recibido",
+ PROCESSING: "Procesando…",
+ READY_FOR_REVIEW: "Listo para revisar",
+ COMPLETED: "Registrado",
+ FAILED: "Falló",
+};
+
+export function StatementIntake() {
+ const canIngest = useCan("statement:ingest");
+ const [batches, setBatches] = useState([]);
+ const [ocrAvailable, setOcrAvailable] = useState(null);
+ const [storageAvailable, setStorageAvailable] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const load = useCallback(async () => {
+ try {
+ const [list, status] = await Promise.all([
+ listStatementBatches(),
+ getStatementStatus(),
+ ]);
+ setBatches(list.items);
+ setOcrAvailable(status.ocrAvailable);
+ setStorageAvailable(status.storageAvailable);
+ setError(null);
+ } catch (e) {
+ setError((e as Error)?.message ?? "No se pudieron cargar los lotes.");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ // A batch of 300 pages takes minutes to OCR, so the list refreshes itself
+ // while anything is still working rather than making staff reload.
+ const working = batches.some(
+ (b) => b.status === "PROCESSING" || b.status === "UPLOADED",
+ );
+ useEffect(() => {
+ if (!working) return;
+ const t = setInterval(() => void load(), 4000);
+ return () => clearInterval(t);
+ }, [working, load]);
+
+ const ready = ocrAvailable === true && storageAvailable === true;
+
+ return (
+
+ {ocrAvailable === false && (
+
+ Este servidor no tiene OCR instalado, así que no se pueden leer recibos
+ escaneados. Usa la captura manual; el resto del sistema funciona con
+ normalidad.
+
+ )}
+
+ {storageAvailable === false && (
+
+ Este servidor no tiene configurado el almacenamiento de documentos, así
+ que no hay dónde guardar los recibos escaneados. Usa la captura manual
+ mientras se configura.
+
+ Todavía no hay lectura automática para{" "}
+ {SERVICE_KIND_LABELS[serviceKind] ?? serviceKind}: cada página quedará
+ para revisión manual. Al confirmarlas se guarda el número de cuenta,
+ así que los recibos del mes siguiente sí se reconocerán solos.
+
+ )}
+
+
+ Un lote es de un solo servicio. Cada página del PDF se trata como un
+ recibo distinto, salvo que el proveedor imprima varias hojas por cliente.
+
+
+ );
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 75be613..dc9bb2f 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -905,8 +905,14 @@ export function reportDownloadUrl(
/* ------------------------------------- Statement OCR intake (recibos) */
-/** Whether this deployment has the OCR binaries — upload is hidden without. */
-export function getStatementStatus(): Promise<{ ocrAvailable: boolean }> {
+/**
+ * Whether this deployment can ingest scans — automatic capture is hidden
+ * without it. OCR reads the page, object storage keeps it; both are required.
+ */
+export function getStatementStatus(): Promise<{
+ ocrAvailable: boolean;
+ storageAvailable: boolean;
+}> {
return apiFetch("/statements/status");
}
diff --git a/docs/RECEIPT_CAPTURE_SPEC.md b/docs/RECEIPT_CAPTURE_SPEC.md
index 5486a74..c21e065 100644
--- a/docs/RECEIPT_CAPTURE_SPEC.md
+++ b/docs/RECEIPT_CAPTURE_SPEC.md
@@ -136,9 +136,19 @@ single-movement form.
> `OcrProvider` seam with a self-hosted Tesseract implementation, per-provider
> parsers for CFE / CESPT / Telnor, a scoped matcher, and a review queue that
> posts through `BillingService.createBatch` with `source: "OCR"`. Web:
-> `/recibos` (upload + batch list) and `/recibos/:id` (review queue with the
-> page image beside the extracted fields). New abilities `statement:ingest` /
-> `statement:review`, both STAFF.
+> the "Captura automática (OCR)" tab of the Captura screen (upload + batch
+> list) and `/recibos/:id` (review queue with the page image beside the
+> extracted fields). New abilities `statement:ingest` / `statement:review`, both
+> STAFF.
+>
+> Auto-capture is a *mode of* §1.2's capture screen, not a separate menu entry:
+> it is the same daily job with a scanner instead of a keyboard, and both modes
+> post through the same ledger path. `/estado-cuenta/lote` opens the manual tab,
+> `/recibos` the automatic one; both render `components/Captura.tsx`.
+>
+> Requires object storage (`S3_ENDPOINT` + credentials): the scans are kept as
+> blobs. `GET /statements/status` reports `ocrAvailable` and `storageAvailable`,
+> and the upload card hides itself unless both hold.
>
> **Measured, not assumed.** Ten real scans (46 pages of CFE, CESPT and Telnor
> bills) drove every decision below. Against them the shipped parser identifies