feat(captura): fold recibo OCR into Captura as an automatic mode
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m46s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s

Scanning a stack of bills and keying them in are the same daily job, ending
in the same ledger path, so OCR intake becomes a mode of the capture screen
instead of a second menu entry:

- components/Captura.tsx holds the mode switch; the manual check form moves
  verbatim to components/ManualCheckCapture.tsx and the OCR intake to
  components/StatementIntake.tsx.
- /estado-cuenta/lote opens on manual, /recibos on automatic — both render
  Captura, so batch-review links and old bookmarks still land right.
- Nav drops "Recibos (OCR)"; "Captura" covers both, with a NavLink.aliases
  field so /recibos still highlights it.

Also fixes the "El almacenamiento de documentos no está configurado" failure
staff hit on upload. Uploading with no object storage configured used to
succeed, then die on the first put minutes later, leaving a FAILED batch
whose only explanation was that string. createBatch now refuses up front,
GET /statements/status reports storageAvailable alongside ocrAvailable, and
the intake tab explains the situation instead of offering an upload that
cannot work. S3_* documented in .env.example (deploy stacks already set it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 01:04:09 -07:00
co-authored by Claude Opus 5
parent 4d5008b545
commit b59abda895
13 changed files with 981 additions and 824 deletions
+22 -7
View File
@@ -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;
}