refactor: Update invoice extraction instructions and prompts for improved clarity and accuracy. Enhance JSON extraction rules to include observation fields when specified, and refine guidance on document processing. Remove obsolete configuration files and assets from the web application.

This commit is contained in:
erik
2026-07-02 01:13:52 -05:00
parent 6ee6df5469
commit 78df190d27
20 changed files with 7 additions and 993 deletions
+5 -1
View File
@@ -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."
)
+2 -1
View File
@@ -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 = [
-41
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
-36
View File
@@ -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.
-16
View File
@@ -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;
-6
View File
@@ -1,6 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
};
export default nextConfig;
-7
View File
@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

-128
View File
@@ -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 (
<div className="flex h-screen overflow-hidden font-sans text-slate-900 selection:bg-blue-100">
{/* Sidebar */}
<aside className={cn(
"bg-white border-r border-slate-200/60 flex flex-col transition-all duration-300 ease-in-out z-40 h-screen",
collapsed ? "w-20" : "w-64"
)}>
{/* Logo */}
<div className={cn("p-5 flex items-center gap-3", collapsed && "justify-center")}>
<div className="bg-blue-600 p-2.5 rounded-xl shadow-lg shadow-blue-200 flex-shrink-0">
<Database className="w-5 h-5 text-white" />
</div>
{!collapsed && (
<div>
<h1 className="text-base font-black tracking-tight text-slate-900 leading-none">IPSExtractor</h1>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">Enterprise</p>
</div>
)}
</div>
<div className="h-px bg-slate-100 mx-4" />
{/* Nav */}
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
{!collapsed && (
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest px-3 pb-2">NAVEGACIÓN</p>
)}
{NAV_ITEMS.map(({ href, icon: Icon, label }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-150 group",
active
? "bg-blue-600 text-white shadow-lg shadow-blue-200"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-900"
)}
>
<Icon className={cn("w-5 h-5 flex-shrink-0", !active && "group-hover:text-blue-600")} />
{!collapsed && <span className="text-sm font-semibold tracking-tight truncate">{label}</span>}
</Link>
);
})}
</nav>
{/* Footer */}
<div className="p-3 space-y-1 border-t border-slate-100">
<button
onClick={() => setCollapsed(!collapsed)}
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl text-slate-400 hover:bg-slate-100 hover:text-slate-700 transition-all"
>
{collapsed
? <ChevronRight className="w-5 h-5 mx-auto" />
: <><ChevronLeft className="w-5 h-5" /><span className="text-xs font-semibold">Colapsar</span></>
}
</button>
{!collapsed && (
<div className="mt-2 p-3 bg-slate-50 rounded-2xl flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
AD
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-slate-900 truncate">Admin User</p>
<p className="text-[10px] text-slate-400 truncate">admin@ipsextractor.com</p>
</div>
<LogOut className="w-4 h-4 text-slate-400 hover:text-red-500 cursor-pointer flex-shrink-0" />
</div>
)}
</div>
</aside>
{/* Main area */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Topbar */}
<header className="bg-white/80 backdrop-blur-md border-b border-slate-200/60 px-6 py-3.5 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-3">
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">
{NAV_ITEMS.find(n => n.href === "/" ? pathname === "/" : pathname.startsWith(n.href))?.label || ""}
</span>
</div>
<div className="flex items-center gap-4">
<button className="p-2 text-slate-400 hover:text-blue-600 relative transition-colors">
<Bell className="w-5 h-5" />
<span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 bg-red-500 rounded-full" />
</button>
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white text-xs font-bold">
AD
</div>
</div>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</div>
);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-22
View File
@@ -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; }
-31
View File
@@ -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 (
<html
lang="es"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full bg-[#F8FAFC]">
<AppShell>{children}</AppShell>
</body>
</html>
);
}
-372
View File
@@ -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 (
<div className={cn("bg-white rounded-2xl border border-slate-200/60 shadow-sm", className)}>
{children}
</div>
);
}
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 (
<Card className="p-5">
<div className="flex items-start justify-between mb-4">
<div className={cn("p-2.5 rounded-xl", c.bg, "shadow-lg", c.glow)}>
<Icon className={cn("w-5 h-5", c.text)} />
</div>
</div>
<p className="text-2xl font-black text-slate-900 tracking-tight">{value}</p>
<p className="text-xs font-semibold text-slate-500 mt-0.5">{label}</p>
{sub && <p className="text-[11px] text-slate-400 mt-1">{sub}</p>}
</Card>
);
}
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 (
<div className="space-y-6">
{/* Radicado actual */}
{batch?.current_radicado && (
<div className="flex items-center gap-3 bg-blue-50 border border-blue-100 rounded-xl px-4 py-2.5">
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse flex-shrink-0" />
<div className="min-w-0">
<p className="text-[10px] text-blue-500 font-bold uppercase tracking-wider">Procesando ahora</p>
<p className="text-sm font-semibold text-blue-900 truncate">{batch.current_radicado}</p>
</div>
</div>
)}
{/* Pipeline steps */}
<div className="relative">
{/* Connecting line */}
<div className="absolute top-6 left-6 right-6 h-0.5 bg-slate-100 z-0" />
<div
className="absolute top-6 left-6 h-0.5 bg-gradient-to-r from-blue-500 to-blue-400 z-0 transition-all duration-700"
style={{ width: `${(phaseIdx / (PHASES.length - 1)) * (100 - 12)}%` }}
/>
<div className="relative z-10 grid grid-cols-4 gap-2">
{PHASES.map((phase, i) => {
const done = i < phaseIdx;
const active = i === phaseIdx && batch?.status === "processing";
const pending = i > phaseIdx;
const Icon = phase.icon;
return (
<div key={phase.key} className="flex flex-col items-center gap-2">
<div className={cn(
"w-12 h-12 rounded-2xl flex items-center justify-center transition-all duration-500",
done ? "bg-blue-600 shadow-lg shadow-blue-200"
: active ? "bg-blue-600 shadow-xl shadow-blue-300 scale-110 ring-4 ring-blue-100"
: "bg-slate-100"
)}>
{done ? (
<CheckCircle2 className="w-5 h-5 text-white" />
) : active ? (
<RefreshCw className="w-5 h-5 text-white animate-spin" />
) : (
<Icon className="w-5 h-5 text-slate-400" />
)}
</div>
<div className="text-center">
<p className={cn(
"text-[11px] font-bold transition-colors",
active ? "text-blue-600" : done ? "text-slate-700" : "text-slate-400"
)}>{phase.label}</p>
{active && (
<p className="text-[10px] text-slate-400 mt-0.5">{phase.desc}</p>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Progress bar */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-bold text-slate-700">Progreso Global</span>
<span className="text-sm font-black text-slate-900">{progress} / {total}</span>
</div>
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-blue-400 rounded-full transition-all duration-700"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex justify-between text-[10px] font-bold text-slate-400">
<span>{Math.round(pct)}% completado</span>
<span>{total - progress} restantes</span>
</div>
</div>
{/* Live stats */}
<div className="grid grid-cols-3 gap-3">
{[
{ 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 => (
<div key={s.label} className={cn("rounded-xl p-3 text-center", s.color.split(" ")[1])}>
<p className="text-lg font-black">{s.value}</p>
<p className={cn("text-[10px] font-bold uppercase tracking-wide", s.color.split(" ")[0])}>{s.label}</p>
</div>
))}
</div>
</div>
);
}
function EmptyProcess() {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-20 h-20 bg-slate-100 rounded-3xl flex items-center justify-center mb-4">
<FileSearch className="w-10 h-10 text-slate-300" />
</div>
<h3 className="text-lg font-bold text-slate-700 mb-1">Sin procesos activos</h3>
<p className="text-sm text-slate-400 max-w-xs">Inicia un proceso desde la aplicación desktop para verlo aquí en tiempo real.</p>
</div>
);
}
function RecentBatchRow({ batch }) {
const pct = Math.min(((batch.current_radicado_index || 0) / (batch.total_radicados || 1)) * 100, 100);
return (
<Link
href="/processes"
className="flex items-center gap-4 p-4 hover:bg-slate-50 rounded-xl transition-colors group"
>
<div className={cn(
"w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0",
batch.status === "completed" ? "bg-emerald-50" : "bg-red-50"
)}>
{batch.status === "completed"
? <CheckCircle2 className="w-4 h-4 text-emerald-600" />
: <AlertCircle className="w-4 h-4 text-red-500" />
}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-800 truncate">{batch.root_path.split(/[\\/]/).pop()}</p>
<div className="flex items-center gap-2 mt-0.5">
<div className="flex-1 h-1 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-blue-400 rounded-full" style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] font-bold text-slate-400 flex-shrink-0">{batch.success_count || 0} ok</span>
</div>
</div>
<div className="text-right flex-shrink-0">
<p className="text-xs font-bold text-slate-900">${(batch.total_cost_usd || 0).toFixed(3)}</p>
<p className="text-[10px] text-slate-400">{(batch.total_tokens || 0).toLocaleString()} tok.</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:text-blue-500 transition-colors" />
</Link>
);
}
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 (
<div className="p-6 max-w-7xl mx-auto space-y-6">
{/* Status pill */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-black text-slate-900">Dashboard</h1>
<p className="text-sm text-slate-400 mt-0.5">Monitoreo en tiempo real del procesamiento</p>
</div>
<div className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-full text-[11px] font-bold",
wsStatus === "connected" ? "bg-emerald-50 text-emerald-600" : "bg-slate-100 text-slate-500"
)}>
{wsStatus === "connected"
? <Wifi className="w-3.5 h-3.5" />
: <WifiOff className="w-3.5 h-3.5" />
}
{wsStatus === "connected" ? "En Vivo" : "Sin conexión"}
</div>
</div>
{/* KPIs */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KpiCard label="Lotes totales" value={stats.total} icon={History} color="blue" />
<KpiCard label="Radicados procesados" value={stats.processed.toLocaleString()} icon={Activity} color="emerald" sub={`en ${completed.length} lotes`} />
<KpiCard label="Tokens consumidos" value={(stats.tokens / 1000000).toFixed(2) + "M"} icon={TrendingUp} color="amber" />
<KpiCard label="Inversión total" value={`$${stats.cost.toFixed(2)}`} icon={DollarSign} color="violet" sub="USD acumulado" />
</div>
{/* Main content: Process live + Recents */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* Live process */}
<div className="lg:col-span-3">
<Card className="h-full">
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn(
"w-2.5 h-2.5 rounded-full",
activeProcess ? "bg-blue-500 animate-pulse" : "bg-slate-300"
)} />
<h2 className="font-bold text-slate-800">
{activeProcess ? "Proceso en Ejecución" : "Sin proceso activo"}
</h2>
</div>
{activeProcess && (
<div className="flex items-center gap-1.5 bg-blue-50 text-blue-600 text-[11px] font-bold px-2.5 py-1 rounded-full">
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-ping" />
EN VIVO
</div>
)}
</div>
<div className="p-5">
{activeProcess
? <PipelineVisual batch={activeProcess} />
: <EmptyProcess />
}
</div>
{activeProcess && (
<div className="px-5 pb-5">
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-3">
<FolderOpen className="w-4 h-4 text-slate-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">Ruta de origen</p>
<p className="text-xs font-mono text-slate-600 break-all">{activeProcess.root_path}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-3">
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className="font-semibold">Equipo:</span>
<span className="font-mono text-slate-700">{activeProcess.hostname || "—"}</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span className="font-semibold">Usuario:</span>
<span className="font-mono text-slate-700">{activeProcess.username || "—"}</span>
</div>
</div>
</div>
)}
</Card>
</div>
{/* Right column */}
<div className="lg:col-span-2 space-y-4">
{/* Cost live if active */}
{activeProcess && (
<Card className="p-5">
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-4">Costo en Vivo</h3>
<div className="text-center">
<p className="text-4xl font-black text-slate-900 tracking-tight">
${(activeProcess.total_cost_usd || 0).toFixed(4)}
</p>
<p className="text-sm text-slate-400 mt-1">USD hasta ahora</p>
<div className="mt-4 pt-4 border-t border-slate-100 flex justify-around">
<div className="text-center">
<p className="text-lg font-black text-blue-600">{(activeProcess.total_tokens || 0).toLocaleString()}</p>
<p className="text-[10px] font-bold text-slate-400 uppercase">Tokens</p>
</div>
<div className="text-center">
<p className="text-lg font-black text-emerald-600">{activeProcess.success_count || 0}</p>
<p className="text-[10px] font-bold text-slate-400 uppercase">Exitosos</p>
</div>
<div className="text-center">
<p className="text-lg font-black text-red-500">{activeProcess.error_count || 0}</p>
<p className="text-[10px] font-bold text-slate-400 uppercase">Errores</p>
</div>
</div>
</div>
</Card>
)}
{/* Recent */}
<Card>
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<h3 className="font-bold text-slate-800">Recientes</h3>
<Link href="/processes" className="text-[11px] font-bold text-blue-600 hover:text-blue-700 flex items-center gap-1">
Ver todos <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="p-2">
{recentDone.length > 0
? recentDone.map(b => <RecentBatchRow key={b.id} batch={b} />)
: (
<p className="text-center text-sm text-slate-400 py-8">
Aún no hay procesos completados.
</p>
)
}
</div>
</Card>
</div>
</div>
</div>
);
}
-321
View File
@@ -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 (
<span className={cn("inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold border", c.cls)}>
<span className={cn("w-1.5 h-1.5 rounded-full", c.dot)} />
{c.label}
</span>
);
}
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button onClick={copy} className="p-1 text-slate-300 hover:text-blue-500 rounded transition-all" title="Copiar ruta">
{copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
);
}
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 (
<div className="border-t border-slate-100 bg-[#0f1117] rounded-b-2xl overflow-hidden">
{/* Terminal header */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-white/5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500/80" />
<span className="w-2.5 h-2.5 rounded-full bg-amber-400/80" />
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500/80" />
<span className="ml-2 text-[10px] font-mono text-slate-500 uppercase tracking-widest">Historial de logs</span>
{logs && <span className="ml-auto text-[10px] font-mono text-slate-600">{logs.length} entradas</span>}
</div>
<div className="max-h-72 overflow-y-auto p-3 space-y-0.5 font-mono text-[11px] scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent">
{loading && (
<p className="text-slate-500 py-6 text-center animate-pulse">Cargando logs…</p>
)}
{!loading && logs && logs.length === 0 && (
<p className="text-slate-600 py-6 text-center">Sin registros para este proceso.</p>
)}
{!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 (
<div key={log.id} className="flex items-start gap-2.5 py-0.5 hover:bg-white/[0.03] rounded px-1">
<span className="text-slate-700 flex-shrink-0 tabular-nums">{time}</span>
<span className={cn("flex-shrink-0 w-[30px] text-right font-bold text-[9px] uppercase", style.cls)}>{style.label}</span>
{log.radicado && (
<span className="flex-shrink-0 bg-white/5 text-slate-400 px-1.5 py-0 rounded text-[9px] leading-4 max-w-[120px] truncate">
{log.radicado}
</span>
)}
<span className={cn("flex-1 leading-relaxed", style.cls, log.level === "error" ? "font-semibold" : "")}>{log.message}</span>
</div>
);
})}
</div>
</div>
);
}
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 (
<div className={cn(
"bg-white rounded-2xl border shadow-sm overflow-hidden transition-all",
isActive ? "border-blue-200 ring-1 ring-blue-100" : "border-slate-200/60"
)}>
{/* Header */}
<div className="p-5 flex items-start gap-4">
<div className={cn(
"w-11 h-11 rounded-2xl flex items-center justify-center flex-shrink-0",
batch.status === "completed" ? "bg-emerald-50" : batch.status === "failed" ? "bg-red-50" : "bg-blue-50"
)}>
{batch.status === "completed" ? <CheckCircle2 className="w-5 h-5 text-emerald-600" />
: batch.status === "failed" ? <AlertCircle className="w-5 h-5 text-red-500" />
: <RefreshCw className="w-5 h-5 text-blue-600 animate-spin" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1.5">
<h3 className="text-base font-bold text-slate-900 truncate">
{batch.root_path.split(/[\\/]/).pop()}
</h3>
<StatusBadge status={batch.status} />
</div>
{/* Path */}
<div className="flex items-center gap-1.5 mb-2 group">
<FolderOpen className="w-3.5 h-3.5 text-slate-300 flex-shrink-0" />
<p className="text-[11px] font-mono text-slate-400 truncate">{batch.root_path}</p>
<CopyButton text={batch.root_path} />
</div>
{/* Meta */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-slate-400 font-medium">
<span className="flex items-center gap-1.5"><Clock className="w-3.5 h-3.5" /> {formatDate(batch.created_at)}</span>
<span className="flex items-center gap-1.5"><Monitor className="w-3.5 h-3.5" /> {batch.hostname || "—"}</span>
<span className="flex items-center gap-1.5"><User className="w-3.5 h-3.5" /> {batch.username || "—"}</span>
<span className="font-mono text-slate-300 text-[10px]">#{batch.id.slice(0, 8)}</span>
</div>
</div>
</div>
{/* Error banner */}
{batch.last_error && (
<div className="mx-5 mb-4 flex items-start gap-2.5 bg-red-50 border border-red-100 rounded-xl px-3.5 py-3">
<TriangleAlert className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-[11px] font-bold text-red-700 mb-0.5 uppercase tracking-wide">Último error</p>
<p className="text-xs text-red-600 font-mono break-all leading-relaxed">{batch.last_error}</p>
</div>
</div>
)}
{/* Progress bar */}
<div className="px-5 pb-4">
<div className="flex justify-between text-[11px] font-bold text-slate-500 mb-1.5">
<span>{isActive ? `Fase: ${batch.current_phase || "preparando"}` : "Completado"}</span>
<span>{progress} / {total} radicados</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all duration-700", batch.status === "failed" ? "bg-red-400" : "bg-gradient-to-r from-blue-500 to-blue-400")}
style={{ width: `${pct}%` }}
/>
</div>
</div>
{/* Stats footer */}
<div className="border-t border-slate-100 grid grid-cols-4 divide-x divide-slate-100">
{[
{ 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 => (
<div key={s.label} className="p-3 text-center">
<p className={cn("text-sm font-black", s.color)}>{s.value}</p>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-wider mt-0.5">{s.label}</p>
</div>
))}
</div>
{/* Actions bar */}
<div className="border-t border-slate-100 px-5 py-3 flex items-center gap-3">
{batch.status === "completed" && batch.csv_output_path && (
<a
href={`${API_URL}/download/${batch.id}`}
className="inline-flex items-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-bold px-4 py-2 rounded-xl transition-colors shadow-sm shadow-emerald-200"
>
<Download className="w-3.5 h-3.5" /> Descargar CSV
</a>
)}
<button
onClick={() => setShowLogs(v => !v)}
className={cn(
"inline-flex items-center gap-2 text-xs font-bold px-4 py-2 rounded-xl border transition-all",
showLogs
? "bg-slate-900 text-white border-slate-700"
: "bg-white text-slate-600 border-slate-200 hover:border-slate-300 hover:bg-slate-50"
)}
>
<List className="w-3.5 h-3.5" />
{showLogs ? "Ocultar logs" : "Ver logs"}
{showLogs ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
</div>
{/* Log panel */}
<LogPanel batchId={batch.id} open={showLogs} />
</div>
);
}
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 (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-black text-slate-900">Procesos</h1>
<p className="text-sm text-slate-400 mt-0.5">Historial completo de lotes de extracción</p>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Buscar por ruta o carpeta..."
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="flex gap-2">
{[
{ 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 => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={cn(
"px-3 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap",
filter === f.key ? "bg-blue-600 text-white shadow-lg shadow-blue-200" : "bg-white border border-slate-200 text-slate-600 hover:bg-slate-50"
)}
>
{f.label}
</button>
))}
</div>
</div>
{/* List */}
<div className="space-y-4">
{filtered.length > 0 ? (
filtered.map(b => <BatchCard key={b.id} batch={b} />)
) : (
<div className="text-center py-20 bg-white rounded-2xl border border-slate-200/60">
<Search className="w-10 h-10 text-slate-200 mx-auto mb-3" />
<p className="text-slate-500 font-semibold">No se encontraron procesos</p>
<p className="text-sm text-slate-400 mt-1">Prueba con otro filtro o búsqueda</p>
</div>
)}
</div>
</div>
);
}