feat(captura): fold recibo OCR into Captura as an automatic mode
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:
@@ -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<CaptureMode, string> = {
|
||||
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<CaptureMode>(
|
||||
modes.some((m) => m.key === initialMode) ? initialMode : (modes[0]?.key ?? "manual"),
|
||||
);
|
||||
|
||||
if (modes.length === 0) {
|
||||
return (
|
||||
<div className="state-box state-error">
|
||||
No tienes permiso para capturar movimientos.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Captura</h1>
|
||||
<p className="eyebrow">{MODE_HINT[mode]}</p>
|
||||
</div>
|
||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||
Volver a estado de cuenta
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{modes.length > 1 && (
|
||||
<div className="seg" role="tablist" style={{ marginBottom: 16 }}>
|
||||
{modes.map((m) => (
|
||||
<button
|
||||
key={m.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === m.key}
|
||||
className={`seg-btn ${mode === m.key ? "active" : ""}`}
|
||||
onClick={() => setMode(m.key)}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "manual" ? <ManualCheckCapture /> : <StatementIntake />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user