diff --git a/application/profiles.py b/application/profiles.py index 714cc29..192181e 100644 --- a/application/profiles.py +++ b/application/profiles.py @@ -16,7 +16,11 @@ class Profile(TypedDict, total=False): DEFAULT_INVOICE_INSTRUCTION = ( - "Extrae los datos de la factura a un JSON, identificando la tabla principal por sus columnas (código, cantidad, valor unitario) para crear un array con la clave fija 'items', y usando las etiquetas literales del documento como claves para todo el resto de la información. No incluyas el campo 'observaciones' en el JSON. Ten especial cuidado en no confundir el número de autorización con el código." + "Extrae los datos de la factura a un JSON, identificando la tabla principal por sus columnas (código, cantidad, valor unitario) " + "para crear un array con la clave fija 'items', y usando las etiquetas literales del documento como claves para el resto de la información. " + "Si el documento muestra una línea o bloque de texto bajo una etiqueta como «Observación:», «Observaciones» o equivalente, " + "inclúyelo en el JSON con la misma clave que refleje el documento (respeta mayúsculas, tildes y singular/plural tal como aparezcan). " + "Ten especial cuidado en no confundir el número de autorización con el código." ) diff --git a/application/prompts.py b/application/prompts.py index 04a810e..c196b41 100644 --- a/application/prompts.py +++ b/application/prompts.py @@ -25,7 +25,8 @@ def _build_instructions() -> List[str]: f"Los únicos tipos de documento válidos para 'documento_forzate' son: {tipos_documento}. NIT no es válido para ese campo.", "Si entre los valores visibles aparece uno o varios tipos de documento válidos seguidos por su número (por ejemplo: 'CC 123456789' o 'EPT 123456789'), aunque no estén escritos con formato 'clave: valor', y existe el campo solicitado 'documento_forzate', guarda cada valor completo con su sigla exacta.", "Respeta la sigla exacta del documento encontrado. Ejemplo: si el documento dice 'EPT', devuelve 'EPT 123456789' y no lo conviertas a 'PT'.", - "Si ves un campo llamado observacion o parecido tambien incluye ese valor en el JSON" + "Solo si una ruta de observación, nota o texto aclaratorio figura explícitamente en 'campos_requeridos' (con el nombre exacto indicado), " + "extrae el texto completo visible bajo su etiqueta en el documento (p. ej. línea «Observación:» …); no añadas ese campo si no está listado en 'campos_requeridos'.", ] _REGLAS_NOMENCLATURA = [ diff --git a/web-app/.gitignore b/web-app/.gitignore deleted file mode 100644 index 5ef6a52..0000000 --- a/web-app/.gitignore +++ /dev/null @@ -1,41 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# env files (can opt-in for committing if needed) -.env* - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/web-app/AGENTS.md b/web-app/AGENTS.md deleted file mode 100644 index 8bd0e39..0000000 --- a/web-app/AGENTS.md +++ /dev/null @@ -1,5 +0,0 @@ - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. - diff --git a/web-app/CLAUDE.md b/web-app/CLAUDE.md deleted file mode 100644 index 43c994c..0000000 --- a/web-app/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/web-app/README.md b/web-app/README.md deleted file mode 100644 index 66bb426..0000000 --- a/web-app/README.md +++ /dev/null @@ -1,36 +0,0 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/web-app/eslint.config.mjs b/web-app/eslint.config.mjs deleted file mode 100644 index f443835..0000000 --- a/web-app/eslint.config.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; - -const eslintConfig = defineConfig([ - ...nextVitals, - // Override default ignores of eslint-config-next. - globalIgnores([ - // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - ]), -]); - -export default eslintConfig; diff --git a/web-app/next.config.mjs b/web-app/next.config.mjs deleted file mode 100644 index b108e1a..0000000 --- a/web-app/next.config.mjs +++ /dev/null @@ -1,6 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - /* config options here */ -}; - -export default nextConfig; diff --git a/web-app/postcss.config.mjs b/web-app/postcss.config.mjs deleted file mode 100644 index 61e3684..0000000 --- a/web-app/postcss.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -const config = { - plugins: { - "@tailwindcss/postcss": {}, - }, -}; - -export default config; diff --git a/web-app/public/file.svg b/web-app/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/web-app/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web-app/public/globe.svg b/web-app/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/web-app/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web-app/public/next.svg b/web-app/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/web-app/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web-app/public/vercel.svg b/web-app/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/web-app/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web-app/public/window.svg b/web-app/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/web-app/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web-app/src/app/components/AppShell.js b/web-app/src/app/components/AppShell.js deleted file mode 100644 index 89c1b74..0000000 --- a/web-app/src/app/components/AppShell.js +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; -import { useState } from "react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { - Database, LayoutDashboard, History, TrendingUp, Settings, - ChevronLeft, ChevronRight, LogOut, User, Bell -} from "lucide-react"; -import { clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -function cn(...inputs) { - return twMerge(clsx(inputs)); -} - -const NAV_ITEMS = [ - { href: "/", icon: LayoutDashboard, label: "Dashboard" }, - { href: "/processes", icon: History, label: "Procesos" }, - { href: "/analytics", icon: TrendingUp, label: "Analíticas" }, - { href: "/settings", icon: Settings, label: "Configuración" }, -]; - -export default function AppShell({ children }) { - const [collapsed, setCollapsed] = useState(false); - const pathname = usePathname(); - - return ( -
- {/* Sidebar */} - - - {/* Main area */} -
- {/* Topbar */} -
-
- - {NAV_ITEMS.find(n => n.href === "/" ? pathname === "/" : pathname.startsWith(n.href))?.label || ""} - -
-
- -
- AD -
-
-
- - {/* Page content */} -
- {children} -
-
-
- ); -} diff --git a/web-app/src/app/favicon.ico b/web-app/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/web-app/src/app/favicon.ico and /dev/null differ diff --git a/web-app/src/app/globals.css b/web-app/src/app/globals.css deleted file mode 100644 index 291882a..0000000 --- a/web-app/src/app/globals.css +++ /dev/null @@ -1,22 +0,0 @@ -@import "tailwindcss"; - -:root { - --background: #F8FAFC; - --foreground: #0f172a; -} - -body { - background: var(--background); - color: var(--foreground); -} - -::-webkit-scrollbar { width: 5px; height: 5px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: #E2E8F0; border-radius: 10px; } -::-webkit-scrollbar-thumb:hover { background: #CBD5E1; } - -@keyframes spin-slow { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} -.animate-spin-slow { animation: spin-slow 3s linear infinite; } diff --git a/web-app/src/app/layout.js b/web-app/src/app/layout.js deleted file mode 100644 index 3f6445f..0000000 --- a/web-app/src/app/layout.js +++ /dev/null @@ -1,31 +0,0 @@ -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; -import AppShell from "./components/AppShell"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -export const metadata = { - title: "IPSExtractor Enterprise", - description: "Enterprise Document Processing Monitor", -}; - -export default function RootLayout({ children }) { - return ( - - - {children} - - - ); -} diff --git a/web-app/src/app/page.js b/web-app/src/app/page.js deleted file mode 100644 index 0c35d00..0000000 --- a/web-app/src/app/page.js +++ /dev/null @@ -1,372 +0,0 @@ -"use client"; -import { useEffect, useState, useCallback, useMemo } from "react"; -import Link from "next/link"; -import { - Activity, CheckCircle2, AlertCircle, Download, History, - TrendingUp, DollarSign, FileSearch, GitMerge, FileSpreadsheet, - ArrowRight, Cpu, Wifi, WifiOff, RefreshCw, FolderOpen, Clock, - ChevronRight -} from "lucide-react"; -import { clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -function cn(...inputs) { return twMerge(clsx(inputs)); } - -const API_URL = "http://localhost:8000"; - -// --- Definición de fases del pipeline --- -const PHASES = [ - { key: "prepare", label: "Preparación", icon: FolderOpen, desc: "Escaneando documentos..." }, - { key: "extract", label: "Extracción IA", icon: Cpu, desc: "Analizando con LLM..." }, - { key: "normalize", label: "Normalización", icon: GitMerge, desc: "Estructurando datos..." }, - { key: "csv", label: "Exportación", icon: FileSpreadsheet, desc: "Generando CSV..." }, -]; - -function getPhaseIndex(phase) { - if (!phase) return 0; - const idx = PHASES.findIndex(p => p.key === phase); - return idx >= 0 ? idx : 0; -} - -// --- Componentes --- -function Card({ children, className }) { - return ( -
- {children} -
- ); -} - -function KpiCard({ label, value, sub, icon: Icon, color }) { - const colors = { - blue: { bg: "bg-blue-50", text: "text-blue-600", glow: "shadow-blue-100" }, - emerald: { bg: "bg-emerald-50", text: "text-emerald-600", glow: "shadow-emerald-100" }, - amber: { bg: "bg-amber-50", text: "text-amber-600", glow: "shadow-amber-100" }, - violet: { bg: "bg-violet-50", text: "text-violet-600", glow: "shadow-violet-100" }, - }; - const c = colors[color] || colors.blue; - return ( - -
-
- -
-
-

{value}

-

{label}

- {sub &&

{sub}

} -
- ); -} - -function PipelineVisual({ batch }) { - const phaseIdx = getPhaseIndex(batch?.current_phase); - const progress = batch?.current_radicado_index || 0; - const total = batch?.total_radicados || 1; - const pct = Math.min((progress / total) * 100, 100); - - return ( -
- {/* Radicado actual */} - {batch?.current_radicado && ( -
-
-
-

Procesando ahora

-

{batch.current_radicado}

-
-
- )} - - {/* Pipeline steps */} -
- {/* Connecting line */} -
-
- -
- {PHASES.map((phase, i) => { - const done = i < phaseIdx; - const active = i === phaseIdx && batch?.status === "processing"; - const pending = i > phaseIdx; - const Icon = phase.icon; - - return ( -
-
- {done ? ( - - ) : active ? ( - - ) : ( - - )} -
-
-

{phase.label}

- {active && ( -

{phase.desc}

- )} -
-
- ); - })} -
-
- - {/* Progress bar */} -
-
- Progreso Global - {progress} / {total} -
-
-
-
-
- {Math.round(pct)}% completado - {total - progress} restantes -
-
- - {/* Live stats */} -
- {[ - { label: "Exitosos", value: batch?.success_count || 0, color: "text-emerald-600 bg-emerald-50" }, - { label: "Errores", value: batch?.error_count || 0, color: "text-red-500 bg-red-50" }, - { label: "Tokens", value: (batch?.total_tokens || 0).toLocaleString(), color: "text-blue-600 bg-blue-50" }, - ].map(s => ( -
-

{s.value}

-

{s.label}

-
- ))} -
-
- ); -} - -function EmptyProcess() { - return ( -
-
- -
-

Sin procesos activos

-

Inicia un proceso desde la aplicación desktop para verlo aquí en tiempo real.

-
- ); -} - -function RecentBatchRow({ batch }) { - const pct = Math.min(((batch.current_radicado_index || 0) / (batch.total_radicados || 1)) * 100, 100); - return ( - -
- {batch.status === "completed" - ? - : - } -
-
-

{batch.root_path.split(/[\\/]/).pop()}

-
-
-
-
- {batch.success_count || 0} ok -
-
-
-

${(batch.total_cost_usd || 0).toFixed(3)}

-

{(batch.total_tokens || 0).toLocaleString()} tok.

-
- - - ); -} - -export default function HomePage() { - const [batches, setBatches] = useState([]); - const [wsStatus, setWsStatus] = useState("connecting"); - - const fetchBatches = useCallback(() => { - fetch(`${API_URL}/batches`).then(r => r.json()).then(setBatches).catch(console.error); - }, []); - - useEffect(() => { - fetchBatches(); - const ws = new WebSocket("ws://localhost:8000/ws/tracking"); - ws.onopen = () => setWsStatus("connected"); - ws.onclose = () => { setWsStatus("disconnected"); setTimeout(fetchBatches, 5000); }; - ws.onmessage = (event) => { - const { event: evt, data } = JSON.parse(event.data); - if (evt === "batch_created") setBatches(prev => [data, ...prev]); - else if (evt === "batch_updated") setBatches(prev => prev.map(b => b.id === data.id ? data : b)); - }; - return () => ws.close(); - }, [fetchBatches]); - - const activeProcess = useMemo(() => batches.find(b => b.status === "processing"), [batches]); - const recentDone = useMemo(() => batches.filter(b => b.status !== "processing").slice(0, 5), [batches]); - const completed = useMemo(() => batches.filter(b => b.status === "completed"), [batches]); - const stats = useMemo(() => ({ - total: batches.length, - processed: completed.reduce((a, b) => a + (b.success_count || 0), 0), - cost: completed.reduce((a, b) => a + (b.total_cost_usd || 0), 0), - tokens: completed.reduce((a, b) => a + (b.total_tokens || 0), 0), - }), [batches, completed]); - - return ( -
- {/* Status pill */} -
-
-

Dashboard

-

Monitoreo en tiempo real del procesamiento

-
-
- {wsStatus === "connected" - ? - : - } - {wsStatus === "connected" ? "En Vivo" : "Sin conexión"} -
-
- - {/* KPIs */} -
- - - - -
- - {/* Main content: Process live + Recents */} -
- {/* Live process */} -
- -
-
-
-

- {activeProcess ? "Proceso en Ejecución" : "Sin proceso activo"} -

-
- {activeProcess && ( -
- - EN VIVO -
- )} -
-
- {activeProcess - ? - : - } -
- {activeProcess && ( -
-
- -
-

Ruta de origen

-

{activeProcess.root_path}

-
-
-
-
- Equipo: - {activeProcess.hostname || "—"} -
-
- Usuario: - {activeProcess.username || "—"} -
-
-
- )} - -
- - {/* Right column */} -
- {/* Cost live if active */} - {activeProcess && ( - -

Costo en Vivo

-
-

- ${(activeProcess.total_cost_usd || 0).toFixed(4)} -

-

USD hasta ahora

-
-
-

{(activeProcess.total_tokens || 0).toLocaleString()}

-

Tokens

-
-
-

{activeProcess.success_count || 0}

-

Exitosos

-
-
-

{activeProcess.error_count || 0}

-

Errores

-
-
-
-
- )} - - {/* Recent */} - -
-

Recientes

- - Ver todos - -
-
- {recentDone.length > 0 - ? recentDone.map(b => ) - : ( -

- Aún no hay procesos completados. -

- ) - } -
-
-
-
-
- ); -} diff --git a/web-app/src/app/processes/page.js b/web-app/src/app/processes/page.js deleted file mode 100644 index c72fb62..0000000 --- a/web-app/src/app/processes/page.js +++ /dev/null @@ -1,321 +0,0 @@ -"use client"; -import { useEffect, useState, useCallback, useMemo } from "react"; -import { - CheckCircle2, AlertCircle, RefreshCw, Download, - Search, Clock, Monitor, User, FolderOpen, - Copy, Check, ChevronDown, ChevronUp, List, TriangleAlert -} from "lucide-react"; -import { clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -function cn(...inputs) { return twMerge(clsx(inputs)); } - -const API_URL = "http://localhost:8000"; - -function formatDate(iso) { - if (!iso) return "-"; - return new Date(iso).toLocaleString("es-CO", { - year: "numeric", month: "short", day: "numeric", - hour: "2-digit", minute: "2-digit" - }); -} - -function StatusBadge({ status }) { - const cfg = { - processing: { label: "En Vivo", cls: "bg-blue-50 text-blue-600 border-blue-100", dot: "bg-blue-500 animate-pulse" }, - completed: { label: "Completado", cls: "bg-emerald-50 text-emerald-700 border-emerald-100", dot: "bg-emerald-500" }, - failed: { label: "Fallido", cls: "bg-red-50 text-red-700 border-red-100", dot: "bg-red-500" }, - }; - const c = cfg[status] || cfg.failed; - return ( - - - {c.label} - - ); -} - -function CopyButton({ text }) { - const [copied, setCopied] = useState(false); - const copy = () => { - navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - return ( - - ); -} - -const LOG_LEVEL_STYLE = { - info: { cls: "text-slate-400", dot: "bg-slate-300", label: "INFO" }, - success: { cls: "text-emerald-600", dot: "bg-emerald-400", label: "OK" }, - error: { cls: "text-red-500", dot: "bg-red-500", label: "ERR" }, - warning: { cls: "text-amber-500", dot: "bg-amber-400", label: "WARN" }, -}; - -function LogPanel({ batchId, open }) { - const [logs, setLogs] = useState(null); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (!open || logs !== null) return; - setLoading(true); - fetch(`${API_URL}/batches/${batchId}/logs?limit=200`) - .then(r => r.json()) - .then(data => { setLogs(data); setLoading(false); }) - .catch(() => { setLogs([]); setLoading(false); }); - }, [open, batchId, logs]); - - if (!open) return null; - - return ( -
- {/* Terminal header */} -
- - - - Historial de logs - {logs && {logs.length} entradas} -
- -
- {loading && ( -

Cargando logs…

- )} - {!loading && logs && logs.length === 0 && ( -

Sin registros para este proceso.

- )} - {!loading && logs && [...logs].reverse().map((log) => { - const style = LOG_LEVEL_STYLE[log.level] || LOG_LEVEL_STYLE.info; - const time = log.timestamp ? log.timestamp.split("T")[1]?.slice(0, 8) : ""; - return ( -
- {time} - {style.label} - {log.radicado && ( - - {log.radicado} - - )} - {log.message} -
- ); - })} -
-
- ); -} - -function BatchCard({ batch }) { - const progress = batch.current_radicado_index || 0; - const total = batch.total_radicados || 1; - const pct = Math.min((progress / total) * 100, 100); - const isActive = batch.status === "processing"; - const [showLogs, setShowLogs] = useState(false); - - return ( -
- {/* Header */} -
-
- {batch.status === "completed" ? - : batch.status === "failed" ? - : } -
- -
-
-

- {batch.root_path.split(/[\\/]/).pop()} -

- -
- - {/* Path */} -
- -

{batch.root_path}

- -
- - {/* Meta */} -
- {formatDate(batch.created_at)} - {batch.hostname || "—"} - {batch.username || "—"} - #{batch.id.slice(0, 8)} -
-
-
- - {/* Error banner */} - {batch.last_error && ( -
- -
-

Último error

-

{batch.last_error}

-
-
- )} - - {/* Progress bar */} -
-
- {isActive ? `Fase: ${batch.current_phase || "preparando"}` : "Completado"} - {progress} / {total} radicados -
-
-
-
-
- - {/* Stats footer */} -
- {[ - { label: "Exitosos", value: batch.success_count || 0, color: "text-emerald-600" }, - { label: "Errores", value: batch.error_count || 0, color: "text-red-500" }, - { label: "Tokens", value: (batch.total_tokens || 0).toLocaleString(), color: "text-blue-600" }, - { label: "Costo USD", value: `$${(batch.total_cost_usd || 0).toFixed(4)}`, color: "text-slate-700" }, - ].map(s => ( -
-

{s.value}

-

{s.label}

-
- ))} -
- - {/* Actions bar */} -
- {batch.status === "completed" && batch.csv_output_path && ( - - Descargar CSV - - )} - -
- - {/* Log panel */} - -
- ); -} - -export default function ProcessesPage() { - const [batches, setBatches] = useState([]); - const [search, setSearch] = useState(""); - const [filter, setFilter] = useState("all"); - - const fetchBatches = useCallback(() => { - fetch(`${API_URL}/batches`).then(r => r.json()).then(setBatches).catch(console.error); - }, []); - - useEffect(() => { - fetchBatches(); - const ws = new WebSocket("ws://localhost:8000/ws/tracking"); - ws.onopen = fetchBatches; - ws.onmessage = (event) => { - const { event: evt, data } = JSON.parse(event.data); - if (evt === "batch_created") setBatches(prev => [data, ...prev]); - else if (evt === "batch_updated") setBatches(prev => prev.map(b => b.id === data.id ? data : b)); - }; - return () => ws.close(); - }, [fetchBatches]); - - const filtered = useMemo(() => { - return batches - .filter(b => filter === "all" || b.status === filter) - .filter(b => b.root_path.toLowerCase().includes(search.toLowerCase())); - }, [batches, search, filter]); - - const counts = useMemo(() => ({ - all: batches.length, - processing: batches.filter(b => b.status === "processing").length, - completed: batches.filter(b => b.status === "completed").length, - failed: batches.filter(b => b.status === "failed").length, - }), [batches]); - - return ( -
- {/* Header */} -
-

Procesos

-

Historial completo de lotes de extracción

-
- - {/* Filters */} -
-
- - setSearch(e.target.value)} - /> -
-
- {[ - { key: "all", label: `Todos (${counts.all})` }, - { key: "processing", label: `En vivo (${counts.processing})` }, - { key: "completed", label: `Completados (${counts.completed})` }, - { key: "failed", label: `Fallidos (${counts.failed})` }, - ].map(f => ( - - ))} -
-
- - {/* List */} -
- {filtered.length > 0 ? ( - filtered.map(b => ) - ) : ( -
- -

No se encontraron procesos

-

Prueba con otro filtro o búsqueda

-
- )} -
-
- ); -}