"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", "GAS", "PROPERTY_TAX", ]; /** Uploadable, but every page will land in review until a parser learns it. */ const OTHER_KINDS: ServiceKind[] = ["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.
)} {canIngest && ready && } {error &&
{error}
}

Lotes

{loading ? (
Cargando…
) : batches.length === 0 ? (
Todavía no hay lotes de recibos.
) : (
{batches.map((b) => ( ))}
Fecha Servicio Referencia Estado Páginas Subido por
{formatDate(b.createdAt)} {serviceKindLabel(b.serviceKind)} {b.label || "—"} {b.error && (
{b.error}
)}
{b._count?.documents ?? 0} {b.uploadedBy?.name ?? "—"} Revisar
)}
); } function StatusTag({ status }: { status: StatementBatchStatus }) { return {STATUS_LABEL[status] ?? status}; } function UploadCard({ onDone }: { onDone: () => void }) { const [files, setFiles] = useState([]); const [serviceKind, setServiceKind] = useState("ELECTRIC"); const [label, setLabel] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function submit() { if (!files.length) return; setBusy(true); setError(null); try { await uploadStatementBatch(files, serviceKind, label.trim() || undefined); setFiles([]); setLabel(""); onDone(); } catch (e) { setError((e as Error)?.message ?? "No se pudo subir el lote."); } finally { setBusy(false); } } const unsupported = !SUPPORTED.includes(serviceKind); return (

Subir recibos escaneados

{error && (
{error}
)} {unsupported && (
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.

); }