feat: Update .env.example and Docker Compose configurations to enhance service performance and health checks. Adjust OCR settings for improved document processing efficiency, and refine API Gateway health checks for better service monitoring. Revise desktop application documentation to reflect new API Gateway URL and installation instructions.

This commit is contained in:
erik
2026-07-06 04:30:27 -05:00
parent 081da999d4
commit c37c40663d
39 changed files with 4815 additions and 1232 deletions
+24 -3
View File
@@ -37,11 +37,32 @@ REDIS_URL=redis://redis:6379/0
IPS_DOCUMENT_ROOT=./data
# ── Servicio de conteo (count-api) ────────────────────────
OCR_DPI=300
# OCR_DPI: resolución para Tesseract.
# 300 = alta calidad (recomendado para documentos escaneados de baja calidad)
# 150 = 4× más rápido, suficiente para la mayoría de facturas digitales
# Para procesar MILES de archivos se recomienda OCR_DPI=150
OCR_DPI=150
OCR_LANG=spa
OCR_POOL_SIZE=4
# OCR_POOL_SIZE y SYNC_PROCESS_CONCURRENCY: igualar al número de núcleos de CPU.
# Ejemplo: máquina con 8 núcleos → OCR_POOL_SIZE=8, SYNC_PROCESS_CONCURRENCY=8
# El frontend envía BATCH_CONCURRENCY=6 requests simultáneos × SYNC_PROCESS_CONCURRENCY archivos c/u.
OCR_POOL_SIZE=8
SYNC_MAX_FILES=50
SYNC_PROCESS_CONCURRENCY=4
SYNC_PROCESS_CONCURRENCY=8
# Memoria para el contenedor count-api. Con 8 workers Tesseract se necesitan ~3 GB.
COUNT_API_MEMORY=3g
# ── Misc ──────────────────────────────────────────────────
ALLOWED_DOCUMENT_TYPES=CC,TI,CE,RC,PA,PPT,PEP,EPT,NUIP
# ── Desktop app (API Gateway) ─────────────────────────────
# En Windows use 127.0.0.1 en lugar de localhost (evita colgar con IPv6).
# La app desktop guarda esta URL en localStorage; valor por defecto: http://127.0.0.1:8189
# ── Build paralelo (solo si falla docker compose up --build) ──────────────────
# Compose v2.37+ usa Bake por defecto y lanza todos los builds en paralelo.
# Si BuildKit se satura (error "frontend grpc server closed unexpectedly"),
# desactivar Bake y limitar el paralelismo resuelve el problema.
# Descomentar solo si se produce ese error.
#COMPOSE_BAKE=false
#COMPOSE_PARALLEL_LIMIT=2
+42 -10
View File
@@ -7,14 +7,14 @@ Arquitectura de microservicios: cada servicio tiene su propia API, dependencias,
## Servicios
| Servicio | Puerto | Tecnología | Descripción |
|----------|--------|------------|-------------|
| `api_gateway` | 8000 | Python/FastAPI | Puerta de enlace única; enruta hacia los demás servicios |
| `extraction` | 8001 | Python/FastAPI | Extracción LLM de campos; orquestación de jobs y conteo |
| `cleaning` | 8002 | Python/FastAPI | Normalización y limpieza de datos extraídos |
| `count-api` | 8080 (→ 8081) | Go | Conteo de servicios en facturas con OCR + ONNX (sin estado) |
| `database` | 5432 | PostgreSQL 16 | Base de datos compartida |
| `redis` | 6379 | Redis 7 | Cola ARQ para workers de extracción |
| Servicio | Puerto (host) | Puerto (interno) | Tecnología | Descripción |
|----------|---------------|------------------|------------|-------------|
| `api_gateway` | **8189** | 8000 | Python/FastAPI | Única puerta de entrada expuesta al host |
| `extraction` | — | 8001 | Python/FastAPI | Extracción LLM; solo red Docker |
| `cleaning` | — | 8002 | Python/FastAPI | Limpieza; solo red Docker |
| `count-api` | — | 8080 | Go | Conteo OCR+ONNX; solo red Docker |
| `database` | — | 5432 | PostgreSQL 16 | Solo red Docker |
| `redis` | — | 6379 | Redis 7 | Solo red Docker |
| `desktop_app` | local | Electron + React | Aplicación de escritorio |
---
@@ -50,10 +50,42 @@ docker compose up --build
Health checks:
```bash
curl http://localhost:8000/health # gateway (agrega estado de los 3 servicios)
curl http://localhost:8081/health # count-api (directo)
curl http://127.0.0.1:8189/health/live # liveness del gateway (rápido)
curl http://127.0.0.1:8189/health # gateway (agrega estado de los 3 servicios)
curl http://127.0.0.1:8189/jobs # lista de trabajos
```
> **Windows:** use `127.0.0.1` en lugar de `localhost` para el API Gateway. Docker publica el puerto en IPv4; `localhost` puede resolver a `::1` y la conexión queda colgada.
> Los demás servicios (extraction, count-api, postgres, redis) **no** exponen puertos al host; solo el gateway en **8189**.
### Solución a fallos de build paralelo en Docker Desktop
Si `docker compose up --build` falla con `frontend grpc server closed unexpectedly` al construir `count-api`, es porque Docker Desktop (Compose v2.37+) lanza todos los builds en paralelo y BuildKit se satura. Hay dos opciones:
**Opción A — pre-construir count-api antes del stack completo (recomendada):**
```bash
docker compose build count-api
docker compose up --build
```
**Opción B — desactivar Bake y limitar el paralelismo:**
```powershell
# PowerShell
$env:COMPOSE_BAKE="false"
$env:COMPOSE_PARALLEL_LIMIT="2"
docker compose up --build
```
```bash
# bash / WSL
COMPOSE_BAKE=false COMPOSE_PARALLEL_LIMIT=2 docker compose up --build
```
Ver también las variables `COMPOSE_BAKE` y `COMPOSE_PARALLEL_LIMIT` en `.env.example`.
---
## Variables de entorno principales
+33 -17
View File
@@ -6,22 +6,19 @@ services:
POSTGRES_DB: ipsextractor
POSTGRES_USER: ipsextractor
POSTGRES_PASSWORD: ipsextractor
ports:
- "5432:5432"
volumes:
- ipsextractor_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ipsextractor -d ipsextractor"]
interval: 5s
timeout: 5s
retries: 10
retries: 15
start_period: 90s
redis:
image: redis:7-alpine
container_name: ipsextractor-redis
command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru --save ""
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
@@ -66,11 +63,15 @@ services:
PDF_THREAD_COUNT: ${PDF_THREAD_COUNT:-4}
CONCURRENCY_LIMIT: ${CONCURRENCY_LIMIT:-50}
ALLOWED_DOCUMENT_TYPES: ${ALLOWED_DOCUMENT_TYPES:-CC,TI,CE,RC,PA,PPT,PEP,EPT,NUIP}
ports:
- "8001:8001"
volumes:
- ${IPS_DOCUMENT_ROOT:-./data}:/data
- ips_queue:/tmp/ips_queue
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=5)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# Escala horizontalmente: docker compose up --scale worker=4
worker:
@@ -116,34 +117,39 @@ services:
condition: service_completed_successfully
environment:
DATABASE_URL: postgresql+psycopg://ipsextractor:ipsextractor@database:5432/ipsextractor
ports:
- "8002:8002"
count-api:
build:
context: ./services/services-count-facturas-medicas-serving
dockerfile: Dockerfile
container_name: ipsextractor-count-api
restart: unless-stopped
depends_on:
db-migrations:
condition: service_completed_successfully
environment:
PORT: "8080"
OCR_DPI: ${OCR_DPI:-300}
# OCR_DPI: 150 reduce RAM (~4×) frente a 300; suficiente para contar facturas.
OCR_DPI: ${OCR_DPI:-150}
OCR_LANG: ${OCR_LANG:-spa}
# Con varios lotes paralelos desde el desktop, limitar goroutines evita OOM.
OCR_POOL_SIZE: ${OCR_POOL_SIZE:-4}
SYNC_MAX_FILES: ${SYNC_MAX_FILES:-50}
SYNC_PROCESS_CONCURRENCY: ${SYNC_PROCESS_CONCURRENCY:-4}
MAX_PDF_SIZE_MB: ${MAX_PDF_SIZE_MB:-20}
LOG_PIPELINE_TIMING: ${LOG_PIPELINE_TIMING:-false}
ports:
- "8081:8080"
volumes:
- ./services/services-count-facturas-medicas-serving/models/latest:/app/models:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 90s
deploy:
resources:
limits:
memory: 1g
memory: ${COUNT_API_MEMORY:-4g}
api_gateway:
build:
@@ -151,15 +157,25 @@ services:
dockerfile: Dockerfile
container_name: ipsextractor-api
depends_on:
- extraction
- cleaning
- count-api
extraction:
condition: service_healthy
cleaning:
condition: service_started
count-api:
condition: service_healthy
environment:
EXTRACTION_URL: http://extraction:8001
CLEANING_URL: http://cleaning:8002
COUNT_URL: http://count-api:8080
ports:
- "8000:8000"
# Único puerto expuesto al host. Los demás servicios solo son accesibles en la red Docker.
- "0.0.0.0:8189:8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health/live"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
volumes:
ipsextractor_db:
+4
View File
@@ -6,6 +6,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
+4 -3
View File
@@ -7,7 +7,7 @@ Enruta las peticiones del cliente hacia los microservicios internos sin lógica
## Puerto
**8000**
**8189** en el host (mapeado al **8000** interno del contenedor). Es el único puerto publicado por `docker-compose`.
---
@@ -15,6 +15,7 @@ Enruta las peticiones del cliente hacia los microservicios internos sin lógica
| Ruta cliente | Servicio destino | Descripción |
|--------------|------------------|-------------|
| `GET /health/live` | *(local)* | Liveness sin consultar upstreams |
| `GET /health` | Agrega `/health` de los tres servicios | Estado del sistema |
| `GET /models` | `extraction:8001/models` | Modelos LLM disponibles |
| `GET /config` | `extraction:8001/config` + `cleaning:8002/rules` | Configuración |
@@ -26,7 +27,7 @@ Enruta las peticiones del cliente hacia los microservicios internos sin lógica
| `/count/{path}` | `count-api:8080/{path}` | Conteo OCR+ML (quita prefijo) |
El proxy reenvía método, query string, headers y body sin modificaciones.
Timeout: ilimitado (para uploads grandes).
Timeouts: JSON 30s lectura; uploads `/count/*` e ingest 600s.
---
@@ -63,4 +64,4 @@ requirements.txt — fastapi, httpx, uvicorn
docker compose up --build api_gateway
```
API disponible en `http://localhost:8000`.
API disponible en `http://127.0.0.1:8189` (único puerto expuesto por docker-compose).
+88 -16
View File
@@ -1,19 +1,45 @@
"""Gateway HTTP sin compartir codigo interno de los microservicios."""
from __future__ import annotations
import json
import logging
import os
import time
from contextlib import asynccontextmanager
from typing import Any
import httpx
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
_log = logging.getLogger("api_gateway")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
EXTRACTION_URL = os.getenv("EXTRACTION_URL", "http://extraction:8001").rstrip("/")
CLEANING_URL = os.getenv("CLEANING_URL", "http://cleaning:8002").rstrip("/")
COUNT_URL = os.getenv("COUNT_URL", "http://count-api:8080").rstrip("/")
app = FastAPI(title="IPSExtractor API Gateway", version="1.0.0")
# Timeouts: JSON ligero vs uploads OCR/conteo
_TIMEOUT_JSON = httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0)
_TIMEOUT_UPLOAD = httpx.Timeout(connect=10.0, read=600.0, write=600.0, pool=10.0)
_http_client: httpx.AsyncClient | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global _http_client
_http_client = httpx.AsyncClient(
timeout=_TIMEOUT_JSON,
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20),
)
_log.info("Gateway iniciado — extraction=%s cleaning=%s count=%s", EXTRACTION_URL, CLEANING_URL, COUNT_URL)
yield
await _http_client.aclose()
_http_client = None
app = FastAPI(title="IPSExtractor API Gateway", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -24,12 +50,41 @@ app.add_middleware(
)
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - start) * 1000
_log.info("%s %s -> %s (%.0fms)", request.method, request.url.path, response.status_code, elapsed_ms)
return response
def _client() -> httpx.AsyncClient:
if _http_client is None:
raise RuntimeError("HTTP client no inicializado")
return _http_client
def _is_upload_path(path: str) -> bool:
return path.startswith("/count/") or "/ingest" in path or "/upload" in path
def _timeout_for_path(path: str) -> httpx.Timeout:
return _TIMEOUT_UPLOAD if _is_upload_path(path) else _TIMEOUT_JSON
@app.get("/health/live")
async def health_live() -> dict[str, str]:
"""Liveness sin consultar upstreams (para healthcheck de Docker)."""
return {"status": "ok"}
@app.get("/health")
async def health() -> dict[str, Any]:
async with httpx.AsyncClient(timeout=10.0) as client:
extraction = await _safe_health(client, EXTRACTION_URL)
cleaning = await _safe_health(client, CLEANING_URL)
counting = await _safe_health(client, COUNT_URL)
client = _client()
extraction = await _safe_health(client, EXTRACTION_URL)
cleaning = await _safe_health(client, CLEANING_URL)
counting = await _safe_health(client, COUNT_URL)
return {
"status": "ok" if extraction["ok"] and cleaning["ok"] else "degraded",
"services": {
@@ -47,9 +102,9 @@ async def models() -> Response:
@app.get("/config")
async def config() -> dict[str, Any]:
async with httpx.AsyncClient(timeout=15.0) as client:
extraction = await _safe_json(client, f"{EXTRACTION_URL}/config")
cleaning_rules = await _safe_json(client, f"{CLEANING_URL}/rules")
client = _client()
extraction = await _safe_json(client, f"{EXTRACTION_URL}/config")
cleaning_rules = await _safe_json(client, f"{CLEANING_URL}/rules")
return {
"extraction": extraction,
"cleaning": {"rules": cleaning_rules},
@@ -91,7 +146,7 @@ async def count_jobs_proxy(path: str, request: Request) -> Response:
return await _proxy_request(request, f"{EXTRACTION_URL}/count-jobs/{path}")
@app.api_route("/jobs", methods=["GET"])
@app.api_route("/jobs", methods=["GET", "POST"])
async def jobs_root_proxy(request: Request) -> Response:
return await _proxy_request(request, f"{EXTRACTION_URL}/jobs")
@@ -103,7 +158,7 @@ async def jobs_proxy(path: str, request: Request) -> Response:
async def _safe_health(client: httpx.AsyncClient, base_url: str) -> dict[str, Any]:
try:
response = await client.get(f"{base_url}/health")
response = await client.get(f"{base_url}/health", timeout=_TIMEOUT_JSON)
return {"ok": response.is_success, "status_code": response.status_code}
except Exception as exc:
return {"ok": False, "error": str(exc)}
@@ -111,7 +166,7 @@ async def _safe_health(client: httpx.AsyncClient, base_url: str) -> dict[str, An
async def _safe_json(client: httpx.AsyncClient, url: str) -> Any:
try:
response = await client.get(url)
response = await client.get(url, timeout=_TIMEOUT_JSON)
response.raise_for_status()
return response.json()
except Exception as exc:
@@ -119,8 +174,7 @@ async def _safe_json(client: httpx.AsyncClient, url: str) -> Any:
async def _proxy(method: str, url: str) -> Response:
async with httpx.AsyncClient(timeout=60.0) as client:
upstream = await client.request(method, url)
upstream = await _client().request(method, url, timeout=_TIMEOUT_JSON)
return _to_response(upstream)
@@ -131,15 +185,33 @@ async def _proxy_request(request: Request, url: str) -> Response:
for key, value in request.headers.items()
if key.lower() not in {"host", "content-length"}
}
async with httpx.AsyncClient(timeout=None) as client:
upstream = await client.request(
timeout = _timeout_for_path(request.url.path)
try:
upstream = await _client().request(
request.method,
url,
params=request.query_params,
content=body,
headers=headers,
timeout=timeout,
)
return _to_response(upstream)
except httpx.ConnectError as exc:
_log.error("Upstream no alcanzable %s: %s", url, exc)
return Response(
content=json.dumps(
{"detail": "Servicio de conteo no disponible. Puede estar reiniciándose; intente de nuevo."}
),
status_code=503,
media_type="application/json",
)
except httpx.TimeoutException as exc:
_log.error("Timeout upstream %s: %s", url, exc)
return Response(
content=json.dumps({"detail": "Tiempo de espera agotado al contactar el servicio upstream."}),
status_code=504,
media_type="application/json",
)
return _to_response(upstream)
def _to_response(upstream: httpx.Response) -> Response:
+20 -4
View File
@@ -41,7 +41,12 @@ Permite seleccionar carpetas locales, contar servicios en facturas médicas, rev
### Comunicación con el backend
Toda la comunicación pasa por el API Gateway (`http://localhost:8000`).
Toda la comunicación pasa por el API Gateway.
| Entorno | URL por defecto |
|---------|-----------------|
| Desarrollo (`npm run dev`) | `http://127.0.0.1:8189` |
| Instalador (build producción) | `https://pys1.builder.xyz` |
El cliente HTTP es [`src/renderer/lib/apiClient.ts`](src/renderer/lib/apiClient.ts).
### Indexación de carpetas
@@ -84,7 +89,7 @@ src/
| Variable | Default | Descripción |
|----------|---------|-------------|
| `API Gateway URL` | `http://localhost:8000` | Configurable en la pantalla de Configuración |
| `API Gateway URL` | Ver tabla arriba | Configurable en la pantalla de Configuración |
| Ruta local de documentos | — | Seleccionada con el explorador de archivos |
| Ruta en contenedor | `/data` | Prefijo donde Docker monta los documentos |
@@ -101,6 +106,17 @@ npm run dev
## Build para producción
```bash
npm run build # compila el renderer
npm run electron:build # empaqueta la app
npm run build # compila el renderer (TypeScript + Vite)
npm run dist # genera el instalador Windows NSIS
```
El instalador se crea en `release/IPSExtractor-Desktop-1.0.0-Setup.exe`.
### Versión de la app
| Campo | Archivo |
|-------|---------|
| Número (`1.0.0`) | `package.json` → `version` |
| Fecha de release | `src/shared/version.json` → `releaseDate` |
La versión se muestra en el menú lateral y en la esquina superior derecha. Al publicar una nueva versión, actualiza ambos archivos y ejecuta `npm run dist`.
+3 -1
View File
@@ -3,6 +3,8 @@ const fs = require("node:fs/promises");
const path = require("node:path");
const { indexFolder } = require("./folderIndex.cjs");
const { version: appVersion } = require("../package.json");
const DOCUMENT_EXTENSIONS = new Set([".pdf", ".tif", ".tiff", ".png", ".jpg", ".jpeg"]);
function createWindow() {
@@ -11,7 +13,7 @@ function createWindow() {
height: 860,
minWidth: 1080,
minHeight: 720,
title: "IPSExtractor Desktop",
title: `IPSExtractor Desktop v${appVersion}`,
backgroundColor: "#f6f7f9",
webPreferences: {
preload: path.join(__dirname, "preload.cjs"),
+23 -3
View File
@@ -1,6 +1,6 @@
{
"name": "ipsextractor-desktop",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"description": "Desktop extraction client for IPSExtractor microservices.",
"main": "electron/main.cjs",
@@ -10,7 +10,8 @@
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"dist": "npm run build && electron-builder"
"dist": "npm run build && electron-builder --win",
"dist:win": "npm run dist"
},
"dependencies": {
"lucide-react": "^0.468.0",
@@ -37,6 +38,7 @@
"build": {
"appId": "com.ipsextractor.desktop",
"productName": "IPSExtractor Desktop",
"artifactName": "IPSExtractor-Desktop-${version}-Setup.${ext}",
"directories": {
"output": "release"
},
@@ -49,7 +51,25 @@
"category": "public.app-category.productivity"
},
"win": {
"target": "nsis"
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"installerLanguages": [
"es-ES",
"en-US"
],
"language": "1042",
"shortcutName": "IPSExtractor Desktop",
"uninstallDisplayName": "IPSExtractor Desktop ${version}"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
declare const __APP_VERSION__: string;
declare const __APP_RELEASE_DATE__: string;
declare const __DEFAULT_API_BASE_URL__: string;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
import { Loader2 } from "lucide-react";
import type { BackgroundProgress } from "../../shared/types";
interface Props {
progress: BackgroundProgress;
compact?: boolean;
}
function pct(processed: number, total: number): number {
if (total <= 0) return 0;
return Math.min(100, Math.round((processed / total) * 100));
}
export function BackgroundProgressBanner({ progress, compact = false }: Props) {
const percent = pct(progress.processed, progress.total);
const moduleLabel = progress.module === "counting" ? "Conteo" : "Extracción";
return (
<div className={`bg-progress-banner${compact ? " bg-progress-banner--compact" : ""}`}>
<div className="bg-progress-banner-header">
<Loader2 size={compact ? 14 : 16} className="spin" />
<span className="bg-progress-banner-title">
{moduleLabel} en curso — {progress.label}
</span>
<span className="bg-progress-banner-count">
<strong>{progress.processed}</strong> / {progress.total}
{progress.failed > 0 && (
<span className="bg-progress-banner-failed"> · {progress.failed} error(es)</span>
)}
</span>
</div>
<div className="wiz-progress-track bg-progress-banner-track">
<div
className="wiz-progress-fill"
style={{ width: `${percent}%`, transition: "width 0.3s ease" }}
/>
</div>
{!compact && progress.currentFile && (
<p className="bg-progress-banner-file" title={progress.currentFile}>
{progress.currentFile}
</p>
)}
</div>
);
}
@@ -1,200 +1,385 @@
import { AlertCircle, CheckCircle2, Filter, XCircle } from "lucide-react";
import { useMemo, useState } from "react";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
Download,
Info,
Search,
XCircle,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import type { CountJobFile } from "../../shared/types";
interface Props {
files: CountJobFile[];
minServiceCount: number;
onMinServiceCountChange: (v: number) => void;
onToggleValid: (file: CountJobFile, isValid: boolean | null) => void;
validCounts: number[];
onValidCountsChange: (v: number[]) => void;
autoAdjustedFilenames?: Set<string>;
onToggleValid: (file: CountJobFile, isValid: boolean | null) => Promise<void>;
onExportCsv?: () => void;
loading?: boolean;
}
type FilterKey = "all" | "valid" | "invalid" | "errors";
function isCountValid(count: number | null, validCounts: number[]): boolean {
if (validCounts.length === 0) return true;
return validCounts.includes(count ?? 0);
}
export function CountResultsTable({
files,
minServiceCount,
onMinServiceCountChange,
validCounts,
onValidCountsChange,
autoAdjustedFilenames,
onToggleValid,
onExportCsv,
loading,
}: Props) {
const [filter, setFilter] = useState<"all" | "valid" | "invalid" | "unreviewed">("all");
const maxCount = useMemo(() => Math.max(0, ...files.map((f) => f.service_count ?? 0)), [files]);
const filtered = useMemo(() => {
return files.filter((f) => {
if (filter === "valid") return f.is_valid === true || (f.is_valid === null && (f.service_count ?? 0) >= minServiceCount);
if (filter === "invalid") return f.is_valid === false || (f.is_valid === null && (f.service_count ?? 0) < minServiceCount);
if (filter === "unreviewed") return f.is_valid === null;
return true;
});
}, [files, filter, minServiceCount]);
const [filter, setFilter] = useState<FilterKey>("all");
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [toggling, setToggling] = useState<Set<string>>(new Set());
const tagInputRef = useRef<HTMLInputElement>(null);
const summary = useMemo(() => {
const valid = files.filter(
(f) => f.is_valid === true || (f.is_valid === null && (f.service_count ?? 0) >= minServiceCount && f.status === "ok"),
const ok = files.filter((f) => f.status === "ok");
const valid = ok.filter(
(f) => f.is_valid === true || (f.is_valid === null && isCountValid(f.service_count, validCounts)),
).length;
const rejected = files.filter(
(f) =>
f.is_valid === false ||
(f.status === "ok" && f.is_valid === null && !isCountValid(f.service_count, validCounts)),
).length;
const errors = files.filter((f) => f.status === "error").length;
return { valid, errors, total: files.length };
}, [files, minServiceCount]);
return { total: files.length, valid, rejected, errors };
}, [files, validCounts]);
function validityClass(f: CountJobFile): string {
if (f.status === "error") return "bg-red-50 dark:bg-red-900/20";
if (f.is_valid === true) return "bg-green-50 dark:bg-green-900/20";
if (f.is_valid === false) return "bg-red-50 dark:bg-red-900/20";
if ((f.service_count ?? 0) >= minServiceCount) return "bg-green-50 dark:bg-green-900/20";
return "bg-yellow-50 dark:bg-yellow-900/20";
const filtered = useMemo(() => {
let list = files;
if (filter === "valid")
list = list.filter(
(f) =>
f.status === "ok" &&
(f.is_valid === true || (f.is_valid === null && isCountValid(f.service_count, validCounts))),
);
else if (filter === "invalid")
list = list.filter(
(f) =>
f.is_valid === false ||
(f.status === "ok" && f.is_valid === null && !isCountValid(f.service_count, validCounts)),
);
else if (filter === "errors") list = list.filter((f) => f.status === "error");
const q = search.trim().toLowerCase();
if (q) list = list.filter((f) => f.filename.toLowerCase().includes(q) || (f.radicado ?? "").toLowerCase().includes(q));
return list;
}, [files, filter, search, validCounts]);
function toggleExpand(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function validityIcon(f: CountJobFile) {
if (f.status === "error") return <XCircle className="w-4 h-4 text-red-500" />;
if (f.is_valid === true) return <CheckCircle2 className="w-4 h-4 text-green-600" />;
if (f.is_valid === false) return <XCircle className="w-4 h-4 text-red-500" />;
if ((f.service_count ?? 0) >= minServiceCount) return <CheckCircle2 className="w-4 h-4 text-green-600" />;
return <AlertCircle className="w-4 h-4 text-yellow-500" />;
async function handleToggle(file: CountJobFile, val: boolean | null) {
setToggling((prev) => new Set(prev).add(file.id));
try {
await onToggleValid(file, val);
} finally {
setToggling((prev) => {
const next = new Set(prev);
next.delete(file.id);
return next;
});
}
}
function addCount(raw: string) {
const n = parseInt(raw.trim(), 10);
if (!isNaN(n) && n >= 1 && !validCounts.includes(n)) {
onValidCountsChange([...validCounts, n].sort((a, b) => a - b));
}
if (tagInputRef.current) tagInputRef.current.value = "";
}
function removeCount(n: number) {
onValidCountsChange(validCounts.filter((x) => x !== n));
}
function rowStatus(f: CountJobFile): "error" | "valid" | "invalid" {
if (f.status === "error") return "error";
if (f.is_valid === false) return "invalid";
if (f.is_valid === true) return "valid";
return isCountValid(f.service_count, validCounts) ? "valid" : "invalid";
}
const FILTERS: { key: FilterKey; label: string; count: number }[] = [
{ key: "all", label: "Todos", count: files.length },
{ key: "valid", label: "Válidos", count: summary.valid },
{ key: "invalid", label: "Fuera de filtro", count: summary.rejected },
{ key: "errors", label: "Error", count: summary.errors },
];
const filterHint = useMemo(() => {
if (validCounts.length === 0) return "Sin filtro: se incluyen todos los archivos procesados.";
const list = validCounts.map((n) => `${n}`).join(", ");
const word = validCounts.length === 1 ? "servicio" : "servicios";
return `Solo se incluyen facturas con exactamente ${list} ${word}.`;
}, [validCounts]);
return (
<div className="space-y-4">
{/* Summary */}
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-3 text-center">
<p className="text-2xl font-bold text-slate-900 dark:text-white">{summary.total}</p>
<p className="text-xs text-slate-500">Total archivos</p>
<div className="count-results">
{/* ── Summary ── */}
<div className="wiz-stats-row count-results-summary">
<div className="wiz-stat">
<span className="wiz-stat-value">{summary.total}</span>
<span className="wiz-stat-label">Total</span>
</div>
<div className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-3 text-center">
<p className="text-2xl font-bold text-green-700 dark:text-green-400">{summary.valid}</p>
<p className="text-xs text-green-600 dark:text-green-500">Válidos para extracción</p>
<div className="wiz-stat count-stat--valid">
<span className="wiz-stat-value">{summary.valid}</span>
<span className="wiz-stat-label">Válidos</span>
</div>
<div className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-3 text-center">
<p className="text-2xl font-bold text-red-700 dark:text-red-400">{summary.errors}</p>
<p className="text-xs text-red-600 dark:text-red-500">Errores de procesamiento</p>
<div className="wiz-stat count-stat--invalid">
<span className="wiz-stat-value">{summary.rejected}</span>
<span className="wiz-stat-label">Fuera de filtro</span>
</div>
{summary.errors > 0 && (
<div className="wiz-stat count-stat--error">
<span className="wiz-stat-value">{summary.errors}</span>
<span className="wiz-stat-label">Errores</span>
</div>
)}
</div>
{/* ── Filtro por conteo exacto ── */}
<div className="count-threshold">
<div className="count-threshold-header">
<label className="wiz-field-label">Conteos de servicios válidos</label>
{validCounts.length > 0 && (
<button
type="button"
className="count-clear-btn"
onClick={() => onValidCountsChange([])}
title="Quitar todos los filtros"
>
Limpiar
</button>
)}
</div>
<div className="count-tags-input">
{validCounts.map((n) => (
<span key={n} className="count-tag">
= {n}
<button
type="button"
className="count-tag-remove"
onClick={() => removeCount(n)}
title={`Quitar filtro = ${n}`}
>
×
</button>
</span>
))}
<input
ref={tagInputRef}
type="number"
min={1}
step={1}
placeholder={validCounts.length === 0 ? "Escribe un número y presiona Enter…" : "+ número"}
className="count-tag-input"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addCount(e.currentTarget.value);
}
}}
onBlur={(e) => {
if (e.currentTarget.value.trim()) addCount(e.currentTarget.value);
}}
disabled={loading}
/>
</div>
<p className="wiz-hint">{filterHint}</p>
{autoAdjustedFilenames && autoAdjustedFilenames.size > 0 && (
<div className="count-auto-note">
<Info size={13} />
<span>
<strong>{autoAdjustedFilenames.size}</strong> archivo(s) con conteo 0 se ajustaron automáticamente a{" "}
<strong>1</strong> por regla de negocio.
</span>
</div>
)}
</div>
{/* ── Toolbar ── */}
<div className="count-toolbar">
<div className="count-filter-tabs">
{FILTERS.map(({ key, label, count }) => (
<button
key={key}
type="button"
className={`count-filter-tab ${filter === key ? "active" : ""}`}
onClick={() => setFilter(key)}
>
{label}
<span className="count-filter-count">{count}</span>
</button>
))}
</div>
<div className="count-toolbar-right">
<div className="wiz-search-box">
<Search size={14} />
<input
placeholder="Buscar archivo…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{onExportCsv && (
<button type="button" className="btn btn-sm btn-ghost" onClick={onExportCsv}>
<Download size={13} /> CSV
</button>
)}
</div>
</div>
{/* Threshold slider */}
<div className="rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 p-4 space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-700 dark:text-slate-300 flex items-center gap-2">
<Filter className="w-4 h-4" />
Umbral mínimo de servicios
</label>
<span className="text-sm font-bold text-indigo-600 dark:text-indigo-400 tabular-nums">
≥ {minServiceCount}
</span>
</div>
<input
type="range"
min={0}
max={maxCount > 0 ? maxCount : 20}
value={minServiceCount}
onChange={(e) => onMinServiceCountChange(Number(e.target.value))}
className="w-full accent-indigo-600"
disabled={loading}
/>
<p className="text-xs text-slate-500">
Solo se extraerán facturas con {minServiceCount === 0 ? "cualquier número" : `≥ ${minServiceCount}`} de servicio(s).
{minServiceCount > 0 && ` Se excluyen ${files.filter((f) => (f.service_count ?? 0) < minServiceCount && f.is_valid !== true).length} archivo(s).`}
</p>
</div>
{/* Filter tabs */}
<div className="flex gap-1 text-xs">
{(["all", "valid", "invalid", "unreviewed"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 rounded-full border transition-colors ${
filter === f
? "bg-indigo-600 border-indigo-600 text-white"
: "border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700"
}`}
>
{f === "all" ? `Todos (${files.length})` : f === "valid" ? "Válidos" : f === "invalid" ? "Rechazados" : "Sin revisar"}
</button>
))}
</div>
{/* Table */}
<div className="rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="max-h-96 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
{/* ── Table ── */}
<div className="count-table-wrap">
<table className="count-table">
<thead>
<tr>
<th className="count-th">Archivo</th>
<th className="count-th count-th-center count-th-narrow">Servicios</th>
<th className="count-th count-th-center count-th-narrow">Estado</th>
<th className="count-th count-th-center">Validez</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Archivo</th>
<th className="px-3 py-2 text-center text-xs font-medium text-slate-500 uppercase tracking-wider w-24">Servicios</th>
<th className="px-3 py-2 text-center text-xs font-medium text-slate-500 uppercase tracking-wider w-24">Estado</th>
<th className="px-3 py-2 text-center text-xs font-medium text-slate-500 uppercase tracking-wider w-28">Validez</th>
<td colSpan={4} className="count-empty">
{search
? "No hay archivos que coincidan con la búsqueda."
: "No hay archivos en esta categoría."}
</td>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{filtered.length === 0 && (
<tr>
<td colSpan={4} className="px-3 py-6 text-center text-slate-400 text-sm">
No hay archivos que mostrar
</td>
</tr>
)}
{filtered.map((f) => (
<tr key={f.id} className={`${validityClass(f)} transition-colors`}>
<td className="px-3 py-2">
<div className="font-medium text-slate-900 dark:text-white truncate max-w-xs" title={f.filename}>
{f.filename}
</div>
{f.radicado && f.radicado !== f.filename && (
<div className="text-xs text-slate-500">{f.radicado}</div>
)}
{f.status === "error" && (
<div className="text-xs text-red-500 mt-0.5">{f.error}</div>
)}
</td>
<td className="px-3 py-2 text-center">
{f.status === "error" ? (
<span className="text-red-400 text-xs">—</span>
) : (
<span className="font-bold tabular-nums text-slate-900 dark:text-white">
{f.service_count ?? "?"}
</span>
)}
</td>
<td className="px-3 py-2">
<div className="flex justify-center">{validityIcon(f)}</div>
</td>
<td className="px-3 py-2">
{f.status !== "error" && (
<div className="flex gap-1 justify-center">
<button
onClick={() => onToggleValid(f, f.is_valid === true ? null : true)}
disabled={loading}
title="Marcar como válido"
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
f.is_valid === true
? "bg-green-600 text-white"
: "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 hover:bg-green-100 dark:hover:bg-green-900/30"
}`}
>
✓
</button>
<button
onClick={() => onToggleValid(f, f.is_valid === false ? null : false)}
disabled={loading}
title="Rechazar"
className={`px-2 py-0.5 rounded text-xs font-medium transition-colors ${
f.is_valid === false
? "bg-red-600 text-white"
: "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400 hover:bg-red-100 dark:hover:bg-red-900/30"
}`}
>
✗
</button>
)}
{filtered.map((f) => {
const st = rowStatus(f);
const isExpanded = expanded.has(f.id);
const hasServices = f.services && f.services.length > 0;
const isToggling = toggling.has(f.id);
const wasAutoAdjusted = autoAdjustedFilenames?.has(f.filename);
return (
<>
<tr key={f.id} className={`count-row count-row--${st}`}>
<td className="count-td">
<div className="count-file-cell">
{hasServices ? (
<button
type="button"
className="count-expand-btn"
onClick={() => toggleExpand(f.id)}
title={isExpanded ? "Ocultar servicios" : "Ver servicios detectados"}
>
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</button>
) : (
<span className="count-expand-placeholder" />
)}
<div className="count-file-info">
<div className="count-filename-row">
<span className="count-filename" title={f.filename}>{f.filename}</span>
{wasAutoAdjusted && (
<span
className="count-auto-badge"
title="Conteo ajustado de 0 a 1 automáticamente por regla de negocio"
>
auto→1
</span>
)}
</div>
{f.radicado && f.radicado !== f.filename && (
<span className="count-radicado">{f.radicado}</span>
)}
{f.status === "error" && f.error && (
<span className="count-error-msg">{f.error}</span>
)}
</div>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</td>
<td className="count-td count-td-center">
{f.status === "error" ? (
<span className="count-dash">—</span>
) : (
<span className="count-service-num">{f.service_count ?? "?"}</span>
)}
</td>
<td className="count-td count-td-center">
{st === "error" && <XCircle size={15} className="count-icon count-icon--error" />}
{st === "valid" && <CheckCircle2 size={15} className="count-icon count-icon--valid" />}
{st === "invalid" && <AlertCircle size={15} className="count-icon count-icon--warn" />}
</td>
<td className="count-td count-td-center">
{f.status !== "error" && (
<div className="count-validity-btns">
<button
type="button"
className={`count-validity-btn ${f.is_valid === true ? "count-validity-btn--accept" : ""}`}
onClick={() => handleToggle(f, f.is_valid === true ? null : true)}
disabled={loading || isToggling}
title="Marcar como válido manualmente"
>
✓
</button>
<button
type="button"
className={`count-validity-btn ${f.is_valid === false ? "count-validity-btn--reject" : ""}`}
onClick={() => handleToggle(f, f.is_valid === false ? null : false)}
disabled={loading || isToggling}
title="Rechazar manualmente"
>
✗
</button>
</div>
)}
</td>
</tr>
{isExpanded && hasServices && (
<tr key={`${f.id}-services`} className="count-row count-row-expanded">
<td colSpan={4} className="count-services-cell">
<div className="count-services-list">
{f.services.map((svc, i) => (
<div key={i} className="count-service-item">
<span className="count-service-text">{svc.text}</span>
<span className="count-service-prob">{(svc.probability * 100).toFixed(0)}%</span>
</div>
))}
</div>
</td>
</tr>
)}
</>
);
})}
</tbody>
</table>
</div>
</div>
);
@@ -2,20 +2,24 @@ import {
AlertCircle,
ArrowRight,
CheckCircle2,
Clock,
FileText,
FolderOpen,
Hash,
Loader2,
Play,
RotateCcw,
XCircle,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type {
BackgroundProgress,
CountJobFile,
CountSyncResult,
CountingFormState,
CreateCountJobPayload,
DocumentType,
Job,
ManifestEntry,
Task,
} from "../../shared/types";
@@ -29,47 +33,86 @@ interface Props {
client: ApiClient;
localRoot: string;
containerRoot: string;
onStartExtraction: (countingTaskId: string, validCount: number) => void;
/** Job al que pertenece este wizard (para vincular la tarea en la BD) */
job?: Job;
onStartExtraction: (countingTaskId: string, validCount: number, validCounts?: number[]) => void;
/** Callback cuando se guarda la tarea en BD (para el módulo de JobView) */
onTaskCreated?: (task: Task) => void;
/** Callback de progreso en segundo plano, para mostrarlo desde otras secciones. */
onProgress?: (progress: BackgroundProgress) => void;
}
interface CountProgress {
processed: number;
total: number;
failed: number;
currentBatch: string;
startedAt: number;
}
/** Archivos por petición HTTP al count-api (más bajo = primera petición más rápida). */
const BATCH_SIZE = 10;
/** Peticiones HTTP simultáneas al count-api. */
const BATCH_CONCURRENCY = 2;
export function CountingWizard({ client, localRoot, containerRoot, onStartExtraction }: Props) {
function formatEta(progress: CountProgress): string | null {
if (!progress.startedAt || progress.processed === 0) return null;
const elapsed = (Date.now() - progress.startedAt) / 1000;
const rate = progress.processed / elapsed; // archivos/segundo
const remaining = progress.total - progress.processed;
if (rate <= 0 || remaining <= 0) return null;
const secs = Math.round(remaining / rate);
const rateStr = rate >= 1 ? `${rate.toFixed(1)} arch/s` : `1 arch cada ${(1 / rate).toFixed(0)}s`;
if (secs < 60) return `~${secs}s · ${rateStr}`;
return `~${Math.ceil(secs / 60)} min · ${rateStr}`;
}
export function CountingWizard({ client, localRoot, containerRoot, job, onStartExtraction, onTaskCreated, onProgress }: Props) {
const [step, setStep] = useState<CountingStep>(1);
const [maxReached, setMaxReached] = useState<CountingStep>(1);
const [form, setForm] = useState<CountingFormState>({
localRoot,
containerRoot,
includeRejected: false,
minServiceCount: 1,
validCounts: [1],
});
const [docTypes] = useState<DocumentType[]>([
{ name: "Factura", keyword: "Factura_O_Documento_Equivalente" },
]);
const [manifest, setManifest] = useState<ManifestEntry[]>([]);
const [progress, setProgress] = useState<CountProgress>({ processed: 0, total: 0, failed: 0 });
const [indexing, setIndexing] = useState(false);
const [progress, setProgress] = useState<CountProgress>({
processed: 0,
total: 0,
failed: 0,
currentBatch: "",
startedAt: 0,
});
const [savedTask, setSavedTask] = useState<Task | null>(null);
const [savedFiles, setSavedFiles] = useState<CountJobFile[]>([]);
const [autoAdjustedFilenames, setAutoAdjustedFilenames] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const abortRef = useRef(false);
const indexLocalFolder = useCallback(
async (dir: string) => {
const entries = await desktopApi.indexFolder(dir, "general", docTypes, []);
setManifest(entries);
if (entries.length === 0) {
setError("No se encontraron PDF en la carpeta seleccionada.");
} else {
setError(null);
setIndexing(true);
setError(null);
try {
const entries = await desktopApi.indexFolder(dir, "general", docTypes, []);
setManifest(entries);
if (entries.length === 0) {
setError("No se encontraron PDF en la carpeta seleccionada.");
}
return entries;
} catch (e) {
setError(String(e));
setManifest([]);
return [];
} finally {
setIndexing(false);
}
return entries;
},
[docTypes],
);
@@ -85,12 +128,10 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
useEffect(() => {
if (!localRoot) return;
let cancelled = false;
indexLocalFolder(localRoot).catch((e) => {
if (!cancelled) setError(String(e));
indexLocalFolder(localRoot).catch(() => {
if (!cancelled) setError("Error al indexar la carpeta.");
});
return () => {
cancelled = true;
};
return () => { cancelled = true; };
}, [localRoot, indexLocalFolder]);
function goToStep(target: CountingStep) {
@@ -102,11 +143,7 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
const dir = await desktopApi.selectDirectory();
if (!dir) return;
setForm((f) => ({ ...f, localRoot: dir }));
try {
await indexLocalFolder(dir);
} catch (e) {
setError(String(e));
}
await indexLocalFolder(dir);
}, [indexLocalFolder]);
function localToContainer(localPath: string): string {
@@ -126,50 +163,163 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
setError(null);
setSavedTask(null);
setSavedFiles([]);
setProgress({ processed: 0, total: manifest.length, failed: 0 });
const startedAt = Date.now();
setProgress({ processed: 0, total: manifest.length, failed: 0, currentBatch: "", startedAt });
onProgress?.({ module: "counting", total: manifest.length, processed: 0, failed: 0, currentFile: "", running: true, label: "Analizando facturas…" });
goToStep(2);
const allResults: CountSyncResult[] = [];
const allResults: CountSyncResult[] = new Array(manifest.length);
const batches: ManifestEntry[][] = [];
for (let i = 0; i < manifest.length; i += BATCH_SIZE) {
if (abortRef.current) break;
const batch = manifest.slice(i, i + BATCH_SIZE);
try {
const formData = new FormData();
for (const entry of batch) {
const fileData = await desktopApi.readFile(entry.localPath);
const blob = new Blob([fileData.data], { type: "application/pdf" });
formData.append("files", blob, fileData.filename);
}
const response = await client.countFiles(formData, false);
allResults.push(...response.results);
setProgress((p) => ({
...p,
processed: Math.min(p.processed + batch.length, manifest.length),
failed: p.failed + response.failed,
}));
} catch (e) {
for (const entry of batch) {
allResults.push({
filename: entry.filename,
service_count: 0,
status: "error",
services: [],
error: String(e),
batches.push(manifest.slice(i, i + BATCH_SIZE));
}
let nextBatchIdx = 0;
// Shared counters (updated atomically via closure over local variables)
let processedCount = 0;
let failedCount = 0;
// Track which files are currently being sent to show in UI
const activeBatches = new Set<string>();
async function processBatch() {
while (nextBatchIdx < batches.length) {
if (abortRef.current) break;
const batchIdx = nextBatchIdx++;
const batch = batches[batchIdx];
const batchStart = batchIdx * BATCH_SIZE;
const batchLabel = batch.map((e) => e.filename).join(", ");
activeBatches.add(batchLabel);
setProgress((p) => ({ ...p, currentBatch: batch[0]?.filename ?? "" }));
onProgress?.({
module: "counting",
total: manifest.length,
processed: processedCount,
failed: failedCount,
currentFile: batch[0]?.filename ?? "",
running: true,
label: `Leyendo lote ${batchIdx + 1}/${batches.length}…`,
});
try {
// Lectura en paralelo dentro del lote → la petición al count-api sale mucho antes
const fileDataList = await Promise.all(
batch.map(async (entry, fi) => {
onProgress?.({
module: "counting",
total: manifest.length,
processed: processedCount,
failed: failedCount,
currentFile: entry.filename,
running: true,
label: `Preparando archivos (${Math.min(batchStart + fi + 1, manifest.length)}/${manifest.length})…`,
});
return desktopApi.readFile(entry.localPath);
}),
);
onProgress?.({
module: "counting",
total: manifest.length,
processed: processedCount,
failed: failedCount,
currentFile: batch[0]?.filename ?? "",
running: true,
label: `Enviando lote ${batchIdx + 1}/${batches.length} al count-api (${batch.length} PDFs)…`,
});
const formData = new FormData();
for (let i = 0; i < batch.length; i++) {
const blob = new Blob([fileDataList[i].data], { type: "application/pdf" });
formData.append("files", blob, fileDataList[i].filename);
}
const response = await client.countFiles(formData, form.includeRejected);
for (let j = 0; j < response.results.length; j++) {
allResults[batchStart + j] = response.results[j];
}
processedCount += batch.length;
failedCount += response.failed;
} catch (e) {
for (let j = 0; j < batch.length; j++) {
allResults[batchStart + j] = {
filename: batch[j].filename,
service_count: 0,
status: "error",
services: [],
error: String(e),
};
}
processedCount += batch.length;
failedCount += batch.length;
}
activeBatches.delete(batchLabel);
const newProcessed = Math.min(processedCount, manifest.length);
setProgress((p) => ({
...p,
processed: Math.min(p.processed + batch.length, manifest.length),
failed: p.failed + batch.length,
processed: newProcessed,
failed: failedCount,
currentBatch: batch[batch.length - 1]?.filename ?? p.currentBatch,
}));
onProgress?.({
module: "counting",
total: manifest.length,
processed: newProcessed,
failed: failedCount,
currentFile: batch[batch.length - 1]?.filename ?? "",
running: true,
label: "Analizando facturas…",
});
}
}
const workers = Array.from(
{ length: Math.min(BATCH_CONCURRENCY, batches.length) },
() => processBatch(),
);
await Promise.all(workers);
if (abortRef.current) {
setRunning(false);
onProgress?.({
module: "counting",
total: manifest.length,
processed: processedCount,
failed: failedCount,
currentFile: "",
running: false,
label: "Cancelado",
});
return;
}
const finalResults = allResults.filter(Boolean);
// Regla de negocio: conteo 0 → 1
const adjusted = new Set<string>();
for (const r of finalResults) {
if (r.status === "ok" && r.service_count === 0) {
r.service_count = 1;
adjusted.add(r.filename);
}
}
setAutoAdjustedFilenames(adjusted);
onProgress?.({
module: "counting",
total: manifest.length,
processed: finalResults.length,
failed: failedCount,
currentFile: "",
running: true,
label: "Guardando resultados en BD…",
});
try {
const payload: CreateCountJobPayload = {
title: `Conteo — ${form.localRoot.split(/[\\/]/).pop()} (${allResults.length} archivos)`,
results: allResults.map((r) => {
title: `Conteo — ${form.localRoot.split(/[\\/]/).pop()} (${finalResults.length} archivos)`,
job_id: job?.id,
results: finalResults.map((r) => {
const entry = manifest.find((m) => m.filename === r.filename);
return {
radicado: entry?.radicado ?? null,
@@ -187,33 +337,65 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
const files = await client.countJobFiles(task.id);
setSavedTask(task);
setSavedFiles(files);
onTaskCreated?.(task);
} catch (e) {
setError(`Resultados procesados pero no se pudieron guardar en BD: ${e}`);
}
setRunning(false);
onProgress?.({
module: "counting",
total: manifest.length,
processed: finalResults.length,
failed: failedCount,
currentFile: "",
running: false,
label: "Completado",
});
goToStep(3);
}
async function handleToggleValid(file: CountJobFile, isValid: boolean | null) {
if (!savedTask) return;
try {
const updated = await client.markCountFileValid(savedTask.id, file.id, isValid);
setSavedFiles((prev) => prev.map((f) => (f.id === updated.id ? updated : f)));
} catch (e) {
setError(String(e));
}
const updated = await client.markCountFileValid(savedTask.id, file.id, isValid);
setSavedFiles((prev) => prev.map((f) => (f.id === updated.id ? updated : f)));
}
function isFileValid(f: CountJobFile): boolean {
if (f.status !== "ok" || f.is_valid === false) return false;
if (f.is_valid === true) return true;
if (form.validCounts.length === 0) return true;
return form.validCounts.includes(f.service_count ?? 0);
}
function handleStartExtraction() {
if (!savedTask) return;
const validCount = savedFiles.filter(
(f) =>
f.status === "ok" &&
f.is_valid !== false &&
(f.service_count ?? 0) >= form.minServiceCount,
).length;
onStartExtraction(savedTask.id, validCount);
const validCount = savedFiles.filter(isFileValid).length;
onStartExtraction(savedTask.id, validCount, form.validCounts);
}
function handleExportCsv() {
if (!savedFiles.length) return;
const headers = ["filename", "radicado", "service_count", "auto_ajustado", "status", "is_valid", "error"];
const rows = savedFiles.map((f) =>
[
`"${f.filename}"`,
`"${f.radicado ?? ""}"`,
f.service_count ?? "",
autoAdjustedFilenames.has(f.filename) ? "si" : "",
f.status,
f.is_valid === null ? "" : String(f.is_valid),
`"${(f.error ?? "").replace(/"/g, '""')}"`,
].join(","),
);
const csv = [headers.join(","), ...rows].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `conteo-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
function resetWizard() {
@@ -222,35 +404,35 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
setManifest([]);
setSavedTask(null);
setSavedFiles([]);
setAutoAdjustedFilenames(new Set());
setError(null);
setProgress({ processed: 0, total: 0, failed: 0 });
setProgress({ processed: 0, total: 0, failed: 0, currentBatch: "", startedAt: 0 });
if (form.localRoot) {
indexLocalFolder(form.localRoot);
}
}
const canExtract =
savedTask !== null &&
savedFiles.some(
(f) =>
f.status === "ok" &&
f.is_valid !== false &&
(f.service_count ?? 0) >= form.minServiceCount,
);
const canExtract = savedTask !== null && savedFiles.some(isFileValid);
const progressPercent =
progress.total > 0 ? Math.round((progress.processed / progress.total) * 100) : 0;
const eta = running ? formatEta(progress) : null;
return (
<div className="wiz">
<CountingStepper current={step} maxReached={maxReached} onStepClick={goToStep} />
<div className="wiz-body">
{/* ── PASO 1: Carpeta ── */}
{step === 1 && (
<div className="wiz-step">
<div className="wiz-section">
<h3 className="wiz-section-title">Origen de las facturas</h3>
<p className="wiz-desc">
Selecciona la carpeta local que contiene los PDF de facturas medicas a analizar.
El servicio contara cuantos servicios vienen en cada documento usando OCR y ML.
Selecciona la carpeta local con los PDF de facturas médicas.
El servicio contará cuántos servicios contiene cada documento usando OCR.
</p>
</div>
@@ -267,12 +449,13 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span>
)}
</div>
<button type="button" className="btn btn-primary" onClick={selectFolder}>
<FolderOpen size={15} /> Explorar
<button type="button" className="btn btn-primary" onClick={selectFolder} disabled={indexing}>
{indexing ? <Loader2 size={14} className="spin" /> : <FolderOpen size={15} />}
{indexing ? "Indexando…" : "Explorar"}
</button>
</div>
{manifest.length > 0 && (
{manifest.length > 0 && !indexing && (
<div className="wiz-found-badge">
<FileText size={15} />
<span>
@@ -280,23 +463,39 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
</span>
</div>
)}
{form.localRoot && manifest.length === 0 && !error && (
<p className="wiz-hint">Buscando PDF en la carpeta…</p>
{form.localRoot && manifest.length === 0 && !indexing && !error && (
<p className="wiz-hint">No se encontraron PDF en esta carpeta.</p>
)}
</div>
<div className="wiz-section">
<label className="wiz-field-label">Ruta en contenedor</label>
<input
type="text"
value={form.containerRoot}
onChange={(e) => setForm((f) => ({ ...f, containerRoot: e.target.value }))}
placeholder="/data"
/>
<p className="wiz-hint">
Prefijo de ruta dentro del contenedor Docker donde estan montados los archivos.
</p>
</div>
<details className="wiz-advanced">
<summary className="wiz-advanced-trigger">Opciones avanzadas</summary>
<div className="wiz-advanced-body">
<div className="wiz-section">
<label className="wiz-field-label">Ruta en contenedor Docker</label>
<input
type="text"
value={form.containerRoot}
onChange={(e) => setForm((f) => ({ ...f, containerRoot: e.target.value }))}
placeholder="/data"
/>
<p className="wiz-hint">
Prefijo donde están montados los documentos dentro de Docker (para guardar rutas en BD).
</p>
</div>
<div className="wiz-checks-row">
<label className="wiz-check-label">
<input
type="checkbox"
checked={form.includeRejected}
onChange={(e) => setForm((f) => ({ ...f, includeRejected: e.target.checked }))}
/>
Incluir filas rechazadas por el modelo en la respuesta
</label>
</div>
</div>
</details>
</div>
)}
@@ -304,15 +503,15 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
{step === 2 && (
<div className="wiz-step wiz-step-centered">
<div className="wiz-hero">
<div className="wiz-hero-icon">
<div className={`wiz-hero-icon ${running ? "wiz-hero-icon--go" : ""}`}>
{running ? <Loader2 size={28} className="spin" /> : <CheckCircle2 size={28} />}
</div>
<h3 className="wiz-hero-title">
{running ? "Analizando facturas…" : "Analisis completado"}
{running ? "Analizando facturas…" : "Análisis completado"}
</h3>
<p className="wiz-desc">
{running
? "Enviando documentos al servicio de conteo. Se analiza cada PDF con OCR y ML para detectar servicios medicos."
? "Enviando documentos al servicio de conteo. El PDF se analiza con OCR para detectar servicios médicos."
: "Todos los documentos fueron procesados. Revisa los resultados en el siguiente paso."}
</p>
</div>
@@ -320,15 +519,16 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
<div className="wiz-progress">
<div className="wiz-progress-header">
<span>
{running ? "Procesando documentos" : "Procesados"}
{running && progress.currentBatch
? <span className="count-current-file" title={progress.currentBatch}>{progress.currentBatch}</span>
: running ? "Procesando…" : "Completado"}
</span>
<span>
{progress.processed} / {progress.total}
<span className="count-progress-right">
<strong>{progress.processed}</strong> / {progress.total}
{progress.failed > 0 && (
<span style={{ color: "var(--color-error)", marginLeft: 8 }}>
{progress.failed} error(es)
</span>
<span className="count-failed-label"> · {progress.failed} error(es)</span>
)}
{eta && <span className="count-eta"> · {eta}</span>}
</span>
</div>
<div className="wiz-progress-track">
@@ -364,52 +564,35 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
<span className="wiz-count-badge">{savedFiles.length} archivos</span>
)}
</h3>
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={resetWizard}
>
<button type="button" className="btn btn-sm btn-ghost" onClick={resetWizard}>
<RotateCcw size={13} /> Nuevo conteo
</button>
</div>
{savedTask && (
{savedTask ? (
<CountResultsTable
files={savedFiles}
minServiceCount={form.minServiceCount}
onMinServiceCountChange={(v) => setForm((f) => ({ ...f, minServiceCount: v }))}
validCounts={form.validCounts}
onValidCountsChange={(v) => setForm((f) => ({ ...f, validCounts: v }))}
autoAdjustedFilenames={autoAdjustedFilenames}
onToggleValid={handleToggleValid}
onExportCsv={handleExportCsv}
loading={running}
/>
)}
</div>
<div className="wiz-action-group">
<button
type="button"
className="btn btn-primary btn-lg"
onClick={handleStartExtraction}
disabled={!canExtract}
>
<ArrowRight size={18} />
Extraer archivos validos
</button>
{canExtract && (
<p className="wiz-hint" style={{ marginTop: 6 }}>
Se procesaran los archivos con &ge; {form.minServiceCount} servicio(s)
</p>
)}
{!canExtract && savedFiles.length > 0 && (
<p className="wiz-hint" style={{ marginTop: 6 }}>
Ajusta el umbral de servicios minimos para habilitar la extraccion.
</p>
) : (
!error && (
<div className="count-no-results">
<XCircle size={28} />
<p>No se pudieron guardar los resultados.</p>
</div>
)
)}
</div>
</div>
)}
</div>
{/* ── Status / Errores ── */}
{/* ── Toast de errores ── */}
{error && (
<div className="wiz-toast wiz-toast--error">
<AlertCircle size={15} />
@@ -417,7 +600,7 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
</div>
)}
{/* ── Footer de navegacion ── */}
{/* ── Footer de navegación ── */}
<footer className="wiz-nav">
<button
type="button"
@@ -425,7 +608,7 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
onClick={() => goToStep((step - 1) as CountingStep)}
disabled={running || step === 1}
>
Atras
Atrás
</button>
<div className="wiz-nav-dots">
@@ -446,7 +629,7 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
type="button"
className="btn btn-primary"
onClick={runCounting}
disabled={running || !form.localRoot || manifest.length === 0}
disabled={running || !form.localRoot || manifest.length === 0 || indexing}
>
<Play size={15} /> Iniciar conteo
</button>
@@ -468,7 +651,13 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
onClick={handleStartExtraction}
disabled={!canExtract}
>
<Hash size={15} /> Extraer validos
<ArrowRight size={15} />
Extraer válidos
{canExtract && (
<span className="count-valid-badge">
{savedFiles.filter(isFileValid).length}
</span>
)}
</button>
)}
</footer>
@@ -53,6 +53,13 @@ interface ExtractionWizardProps {
canGoNext: boolean;
onBack: () => void;
onNext: () => void;
/**
* Cuando se extrae desde conteo, sobreescribe el conteo de documentos mostrado
* en el resumen del paso 4 (los archivos reales vienen del counting task, no de manifest).
*/
effectiveDocumentCount?: number;
/** Etiqueta descriptiva para el origen de los documentos (ej. "desde conteo") */
documentSourceLabel?: string;
}
const MODELS = [
@@ -114,9 +121,13 @@ export function ExtractionWizard({
canGoNext,
onBack,
onNext,
effectiveDocumentCount,
documentSourceLabel,
}: ExtractionWizardProps) {
const [advancedOpen, setAdvancedOpen] = useState(false);
const fieldCount = Object.keys(flattened.schema).length;
// Número efectivo de documentos: usa el override si está disponible (ej. "desde conteo")
const displayDocCount = effectiveDocumentCount ?? manifest.length;
const ingestPercent = ingestProgress ? Math.round((ingestProgress.processed / ingestProgress.total) * 100) : 0;
return (
@@ -332,8 +343,12 @@ export function ExtractionWizard({
<div className="wiz-stats-row">
<div className="wiz-stat">
<span className="wiz-stat-value">{manifest.length}</span>
<span className="wiz-stat-label">Documentos</span>
<span className="wiz-stat-value" style={effectiveDocumentCount !== undefined ? { color: "var(--primary)" } : undefined}>
{displayDocCount}
</span>
<span className="wiz-stat-label">
{documentSourceLabel ? `Docs (${documentSourceLabel})` : "Documentos"}
</span>
</div>
<div className="wiz-stat">
<span className="wiz-stat-value">{form.mode === "radicados" ? "Radicados" : "General"}</span>
@@ -450,8 +465,12 @@ export function ExtractionWizard({
<div className="wiz-review-item">
<FileText size={18} />
<div>
<span className="wiz-review-label">Documentos</span>
<span className="wiz-review-value">{manifest.length}</span>
<span className="wiz-review-label">
{documentSourceLabel ? `Documentos (${documentSourceLabel})` : "Documentos"}
</span>
<span className="wiz-review-value" style={effectiveDocumentCount !== undefined ? { color: "var(--primary)", fontWeight: 700 } : undefined}>
{displayDocCount}
</span>
</div>
</div>
<div className="wiz-review-item">
@@ -486,7 +505,7 @@ export function ExtractionWizard({
const normUsage = est.normalizer_usage;
const perDoc = est.per_doc_cost;
const total = est.estimated_total_cost;
const docs = est.total_docs ?? manifest.length;
const docs = est.total_docs ?? displayDocCount;
const extModel = est.extractor_model ?? form.model;
const normModel = est.normalizer_model ?? "gpt-5.4-mini";
const extPrompt = extUsage?.prompt_tokens ?? 0;
@@ -2,13 +2,43 @@ import type {
CountJobFile,
CountSyncResponse,
CreateCountJobPayload,
CreateJobPayload,
ExtractionBatch,
ExtractionFromCountPayload,
Job,
RowsResponse,
SchemaResponse,
Task,
UpdateJobPayload,
} from "../../shared/types";
import { DEFAULT_API_BASE_URL } from "./apiDefaults";
const DEFAULT_TIMEOUT_MS = 30_000;
const UPLOAD_TIMEOUT_MS = 600_000;
async function fetchWithTimeout(
url: string,
init: RequestInit = {},
timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<Response> {
try {
return await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
} catch (err) {
if (err instanceof DOMException && err.name === "TimeoutError") {
throw new Error(
`Tiempo de espera agotado al conectar con ${url}. ` +
`Verifica que Docker esté corriendo y usa ${DEFAULT_API_BASE_URL}`,
);
}
if (err instanceof TypeError) {
throw new Error(
`No se pudo conectar al API en ${url}. ` +
`Verifica Docker y usa ${DEFAULT_API_BASE_URL} (evita localhost en Windows).`,
);
}
throw err;
}
}
export class ApiClient {
constructor(private readonly baseUrl: string) {}
@@ -17,25 +47,42 @@ export class ApiClient {
return `${this.baseUrl.replace(/\/+$/, "")}${path}`;
}
private isUploadPath(path: string): boolean {
return (
path.startsWith("/count/") ||
path.includes("/ingest") ||
path.includes("/upload")
);
}
private timeoutFor(path: string): number {
return this.isUploadPath(path) ? UPLOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
}
async getJson<T>(path: string): Promise<T> {
const response = await fetch(this.url(path));
const response = await fetchWithTimeout(this.url(path), {}, this.timeoutFor(path));
return this.handle<T>(response);
}
async postJson<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(this.url(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const response = await fetchWithTimeout(
this.url(path),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
this.timeoutFor(path),
);
return this.handle<T>(response);
}
async postForm<T>(path: string, form: FormData): Promise<T> {
const response = await fetch(this.url(path), {
method: "POST",
body: form,
});
const response = await fetchWithTimeout(
this.url(path),
{ method: "POST", body: form },
this.timeoutFor(path),
);
return this.handle<T>(response);
}
@@ -113,6 +160,22 @@ export class ApiClient {
return this.getJson<Task[]>(`/jobs/${encodeURIComponent(jobId)}/tasks`);
}
async createJob(payload: CreateJobPayload): Promise<Job> {
return this.postJson<Job>("/jobs", payload);
}
async updateJob(jobId: string, payload: UpdateJobPayload): Promise<Job> {
const response = await fetchWithTimeout(
this.url(`/jobs/${encodeURIComponent(jobId)}`),
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
return this.handle<Job>(response);
}
// ── Count Jobs ─────────────────────────────────────────────────────────────
/** Envía PDFs al servicio de conteo (síncrono). */
@@ -139,11 +202,14 @@ export class ApiClient {
}
async markCountFileValid(taskId: string, fileId: string, isValid: boolean | null): Promise<CountJobFile> {
const response = await fetch(this.url(`/count-jobs/${encodeURIComponent(taskId)}/files/${encodeURIComponent(fileId)}`), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_valid: isValid }),
});
const response = await fetchWithTimeout(
this.url(`/count-jobs/${encodeURIComponent(taskId)}/files/${encodeURIComponent(fileId)}`),
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_valid: isValid }),
},
);
return this.handle<CountJobFile>(response);
}
@@ -0,0 +1,25 @@
/** URL por defecto del API Gateway (dev: local Docker; instalador: pys1.builder.xyz). */
export const DEFAULT_API_BASE_URL: string = __DEFAULT_API_BASE_URL__;
/** Reemplaza localhost por 127.0.0.1 para evitar problemas de IPv6 en Windows/Docker. */
export function normalizeApiBaseUrl(url: string | null | undefined): string {
const raw = (url || DEFAULT_API_BASE_URL).trim();
return raw.replace(/:\/\/localhost\b/i, "://127.0.0.1");
}
export function loadApiBaseUrl(): string {
return normalizeApiBaseUrl(localStorage.getItem("apiBaseUrl"));
}
/** Migra localStorage si el usuario tenía localhost o el puerto antiguo 8000. */
export function migrateApiBaseUrlInStorage(): void {
const stored = localStorage.getItem("apiBaseUrl");
if (!stored) return;
let next = normalizeApiBaseUrl(stored);
if (next.includes(":8000")) {
next = next.replace(":8000", ":8189");
}
if (next !== stored) {
localStorage.setItem("apiBaseUrl", next);
}
}
@@ -17,6 +17,7 @@ const baseForm: ExtractionFormState = {
tiffEnabled: true,
tiffStart: "1",
tiffEnd: "3",
model: "gpt-5.4-mini",
};
const manifest: ManifestEntry[] = [
@@ -0,0 +1,18 @@
import { CountingWizardModule } from "./CountingWizardModule";
import { CountingResults } from "./CountingResults";
import type { ModuleDescriptor } from "../registry";
import { registerModule } from "../registry";
import type { Task } from "../../../shared/types";
const CountingModule: ModuleDescriptor = {
type: "counting",
label: "Conteo",
iconName: "Hash",
hasTask: (tasks: Task[]) => tasks.some((t) => t.type === "counting"),
Wizard: CountingWizardModule,
Results: CountingResults,
};
registerModule(CountingModule);
export { CountingModule };
@@ -0,0 +1,148 @@
import { ArrowRight, Download, Loader2, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import type { CountJobFile } from "../../../shared/types";
import { CountResultsTable } from "../../components/CountResultsTable";
import type { ModuleResultsProps } from "../registry";
export function CountingResults({
client,
task,
job,
onTaskUpdated,
onActivateModule,
}: ModuleResultsProps) {
const [files, setFiles] = useState<CountJobFile[]>([]);
const [loading, setLoading] = useState(true);
const [validCounts, setValidCounts] = useState<number[]>([1]);
const [autoAdjustedFilenames] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
client
.countJobFiles(task.id)
.then(setFiles)
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, [client, task.id]);
async function handleToggleValid(file: CountJobFile, isValid: boolean | null) {
const updated = await client.markCountFileValid(task.id, file.id, isValid);
setFiles((prev) => prev.map((f) => (f.id === updated.id ? updated : f)));
}
function isFileValid(f: CountJobFile): boolean {
if (f.status !== "ok" || f.is_valid === false) return false;
if (f.is_valid === true) return true;
if (validCounts.length === 0) return true;
return validCounts.includes(f.service_count ?? 0);
}
function exportCsv(subset: "valid" | "invalid" | "all") {
const subset_files =
subset === "valid"
? files.filter(isFileValid)
: subset === "invalid"
? files.filter((f) => !isFileValid(f))
: files;
const headers = ["filename", "radicado", "service_count", "status", "is_valid", "error"];
const rows = subset_files.map((f) =>
[
`"${f.filename}"`,
`"${f.radicado ?? ""}"`,
f.service_count ?? "",
f.status,
f.is_valid === null ? "" : String(f.is_valid),
`"${(f.error ?? "").replace(/"/g, '""')}"`,
].join(","),
);
const csv = [headers.join(","), ...rows].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const label = subset === "valid" ? "validos" : subset === "invalid" ? "invalidos" : "todos";
a.download = `conteo-${label}-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
const validCount = files.filter(isFileValid).length;
if (loading) {
return (
<div className="module-loading">
<Loader2 size={22} className="spin" />
<span>Cargando resultados…</span>
</div>
);
}
if (error) {
return (
<div className="wiz-toast wiz-toast--error" style={{ margin: 16 }}>
{error}
<button className="btn btn-sm" onClick={() => { setError(null); setLoading(true); client.countJobFiles(task.id).then(setFiles).finally(() => setLoading(false)); }}>
<RefreshCw size={13} /> Reintentar
</button>
</div>
);
}
return (
<div className="module-results">
<div className="module-results-header">
<div className="module-results-title">
<h3>{task.title}</h3>
<span className="wiz-count-badge">{files.length} archivos</span>
</div>
<div className="module-results-actions">
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={() => exportCsv("valid")}
disabled={validCount === 0}
title="Descargar CSV con archivos válidos"
>
<Download size={13} /> CSV válidos ({validCount})
</button>
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={() => exportCsv("invalid")}
disabled={files.filter((f) => !isFileValid(f)).length === 0}
title="Descargar CSV con archivos inválidos/bajo umbral"
>
<Download size={13} /> CSV inválidos
</button>
{onActivateModule && validCount > 0 && (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
onActivateModule("extraction", {
countingTaskId: task.id,
validCounts,
validCount,
jobId: job.id,
})
}
>
<ArrowRight size={14} /> Extraer válidos ({validCount})
</button>
)}
</div>
</div>
<CountResultsTable
files={files}
validCounts={validCounts}
onValidCountsChange={setValidCounts}
autoAdjustedFilenames={autoAdjustedFilenames}
onToggleValid={handleToggleValid}
loading={false}
/>
</div>
);
}
@@ -0,0 +1,30 @@
import { CountingWizard } from "../../components/CountingWizard";
import type { BackgroundProgress } from "../../../shared/types";
import type { ModuleWizardProps } from "../registry";
interface Props extends ModuleWizardProps {
onProgress?: (p: BackgroundProgress) => void;
}
/**
* Wrapper del CountingWizard para el sistema de módulos.
* Pasa el job al wizard para que los resultados queden vinculados.
*/
export function CountingWizardModule({ client, job, onTaskCreated, onActivateModule, onProgress }: Props) {
const folderPath = String(job.metadata?.folder_path ?? "");
const containerRoot = "/data";
return (
<CountingWizard
client={client}
job={job}
localRoot={folderPath}
containerRoot={containerRoot}
onTaskCreated={onTaskCreated}
onProgress={onProgress}
onStartExtraction={(countingTaskId, validCount, validCounts) => {
onActivateModule?.("extraction", { countingTaskId, validCount, validCounts, jobId: job.id });
}}
/>
);
}
@@ -0,0 +1,18 @@
import type { Task } from "../../../shared/types";
import { registerModule } from "../registry";
import type { ModuleDescriptor } from "../registry";
import { JobExtractionWizardModule } from "./JobExtractionWizardModule";
import { ExtractionResults } from "./ExtractionResults";
const ExtractionModule: ModuleDescriptor = {
type: "extraction",
label: "Extracción",
iconName: "Cpu",
hasTask: (tasks: Task[]) => tasks.some((t) => t.type === "extraction"),
Wizard: JobExtractionWizardModule,
Results: ExtractionResults,
};
registerModule(ExtractionModule);
export { ExtractionModule };
@@ -0,0 +1,163 @@
import { Download, Loader2, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import type { ExtractionBatch } from "../../../shared/types";
import type { ModuleResultsProps } from "../registry";
type Row = Record<string, unknown>;
export function ExtractionResults({ client, task }: ModuleResultsProps) {
const [batch, setBatch] = useState<ExtractionBatch | null>(null);
const [rows, setRows] = useState<Row[]>([]);
const [columns, setColumns] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [polling, setPolling] = useState<ReturnType<typeof setInterval> | null>(null);
function stop() {
if (polling !== null) { clearInterval(polling); setPolling(null); }
}
async function load() {
try {
const t = await client.task(task.id);
const pseudoBatch: ExtractionBatch = {
id: String((t.result as Record<string, unknown>)?.extraction_batch_id ?? t.id),
status: t.status as ExtractionBatch["status"],
root_path: "",
request: {},
created_at: t.created_at,
updated_at: t.updated_at,
};
setBatch(pseudoBatch);
if (t.status === "running" || t.status === "pending") return;
stop();
const response = await client.taskRows(t.id, 500, 0);
const data = response.rows ?? [];
setRows(data);
const cols = Array.from(new Set(data.flatMap((r: Record<string, unknown>) => Object.keys(r)))).filter(
(c) => !["_id", "batch_id"].includes(c),
) as string[];
setColumns(cols);
} catch (e) {
setError(String(e));
stop();
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
const id = setInterval(load, 5000);
setPolling(id);
return () => clearInterval(id);
}, [task.id]); // eslint-disable-line react-hooks/exhaustive-deps
function exportCsv() {
const headers = columns;
const csvRows = rows.map((r) =>
headers
.map((h) => {
const v = r[h];
if (v === null || v === undefined) return "";
const s = String(v);
return `"${s.replace(/"/g, '""')}"`;
})
.join(","),
);
const csv = [headers.join(","), ...csvRows].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `extraccion-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
if (loading && !batch) {
return (
<div className="module-loading">
<Loader2 size={22} className="spin" />
<span>Cargando resultados de extracción…</span>
</div>
);
}
if (error) {
return (
<div className="wiz-toast wiz-toast--error" style={{ margin: 16 }}>
{error}
<button className="btn btn-sm" onClick={() => { setError(null); load(); }}>
<RefreshCw size={13} /> Reintentar
</button>
</div>
);
}
const isRunning = batch?.status === "running" || batch?.status === "pending";
return (
<div className="module-results">
<div className="module-results-header">
<div className="module-results-title">
<h3>{task.title}</h3>
<span
className={`task-badge task-badge--${batch?.status ?? "pending"}`}
>
{isRunning && <Loader2 size={11} className="spin" />}
{batch?.status ?? "pendiente"}
</span>
</div>
{rows.length > 0 && (
<button type="button" className="btn btn-sm btn-ghost" onClick={exportCsv}>
<Download size={13} /> Descargar CSV ({rows.length} filas)
</button>
)}
</div>
{isRunning && (
<div className="wiz-progress" style={{ margin: "0 0 12px" }}>
<div className="wiz-progress-header">
<span>Extracción en progreso…</span>
<Loader2 size={13} className="spin" />
</div>
<div className="wiz-progress-track">
<div className="wiz-progress-fill" style={{ width: "100%", animation: "indeterminate 1.4s ease infinite" }} />
</div>
</div>
)}
{rows.length === 0 && !isRunning ? (
<div className="wiz-step wiz-step-centered" style={{ padding: 32 }}>
<p className="wiz-desc">No se encontraron filas extraídas.</p>
</div>
) : (
<div className="results-table-wrapper" style={{ overflowX: "auto" }}>
<table className="results-table">
<thead>
<tr>
{columns.map((c) => (
<th key={c}>{c}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
{columns.map((c) => (
<td key={c}>
{row[c] === null || row[c] === undefined
? <span style={{ color: "var(--text-muted)" }}>—</span>
: String(row[c])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,487 @@
/**
* Wrapper del ExtractionWizard original para el contexto de Job.
* Contiene toda la lógica de estado que antes vivía en App.tsx,
* preservando el diseño paso a paso completo.
*/
import { ArrowRight, CheckCircle2, FileText } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type {
BackgroundProgress,
CountJobFile,
DocumentType,
ExtractionFormState,
FieldSelection,
ManifestEntry,
SchemaResponse,
Task,
} from "../../../shared/types";
import { ExtractionWizard } from "../../components/ExtractionWizard";
import type { ExtractionStep } from "../../components/ExtractionStepper";
import {
buildDesktopBatchPayload,
buildEstimateUploadForm,
buildIngestFormBulk,
buildSchemaUploadForm,
flattenSchema,
mergeSchemaResponses,
validDocTypes,
} from "../../lib/payloads";
import { defaultAlias } from "../../lib/schemaDisplay";
import { loadApiBaseUrl } from "../../lib/apiDefaults";
import { splitRadicados } from "../../lib/pathMapping";
import type { ModuleWizardProps } from "../registry";
declare const desktopApi: import("../../../shared/types").DesktopBridge;
interface Props extends ModuleWizardProps {
/** ID de tarea de conteo previa (desde contexto de JobView) */
countingTaskId?: string;
/** Conteos exactos válidos para filtrar desde el conteo */
validCounts?: number[];
/** Número de archivos válidos del conteo (para mostrar en banner) */
validCount?: number;
/** Callback de progreso en segundo plano, para mostrarlo desde otras secciones. */
onProgress?: (progress: BackgroundProgress) => void;
}
function makeDefaultForm(job: { metadata?: Record<string, unknown> }, fromCount = false): ExtractionFormState {
const folder = String(job.metadata?.folder_path ?? "");
return {
apiBaseUrl: loadApiBaseUrl(),
localRoot: folder || localStorage.getItem("localRoot") || "",
containerRoot: localStorage.getItem("containerRoot") || "/data",
selectedLocalPath: folder,
selectedContainerPath: folder,
// Cuando viene de conteo usamos "general" porque los archivos están en carpeta plana.
// En extracción directa, se puede cambiar en el wizard.
mode: fromCount ? "general" : "radicados",
docTypes: [{ name: "Factura", keyword: "Factura_O_Documento_Equivalente" }],
keywordsText: "factura",
retryOnEmpty: false,
filterEnabled: false,
radicadoFilterText: "",
tiffEnabled: false,
tiffStart: "0",
tiffEnd: "0",
model: localStorage.getItem("extractorModel") || "gpt-5.4",
};
}
export function JobExtractionWizard({
client,
job,
onTaskCreated,
context,
countingTaskId: countingTaskIdProp,
validCounts: validCountsProp,
validCount: validCountProp,
onProgress,
}: Props) {
const countingTaskId = countingTaskIdProp ?? (context?.countingTaskId as string | undefined);
const validCounts = validCountsProp ?? (context?.validCounts as number[] | undefined);
const validCount = validCountProp ?? (context?.validCount as number | undefined);
const [form, setForm] = useState<ExtractionFormState>(() => makeDefaultForm(job, !!countingTaskId));
const [schemaResponse, setSchemaResponse] = useState<SchemaResponse | null>(null);
const [schemaSearch, setSchemaSearch] = useState("");
const [selection, setSelection] = useState<FieldSelection>({ fields: [], aliasMap: {} });
const [estimateJson, setEstimateJson] = useState<unknown>(null);
const [manifest, setManifest] = useState<ManifestEntry[]>([]);
const [ingestProgress, setIngestProgress] = useState<{ processed: number; total: number } | null>(null);
const [statusText, setStatusText] = useState("Listo");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Si viene del conteo, empezamos en paso 2 (los archivos ya están definidos)
const [extractionStep, setExtractionStep] = useState<ExtractionStep>(() => countingTaskId ? 2 : 1);
const [maxReachedStep, setMaxReachedStep] = useState<ExtractionStep>(() => countingTaskId ? 2 : 1);
const [taskCreated, setTaskCreated] = useState<Task | null>(null);
const flattened = useMemo(
() => (schemaResponse ? flattenSchema(schemaResponse) : { schema: {}, sample: null }),
[schemaResponse],
);
const fieldCount = Object.keys(flattened.schema).length;
const canGoNext =
// Paso 1: necesita documentos en carpeta (o saltarse si viene de conteo)
(extractionStep === 1 && (manifest.length > 0 || !!countingTaskId)) ||
// Paso 2: necesita campos descubiertos
(extractionStep === 2 && fieldCount > 0) ||
// Paso 3: necesita al menos un campo seleccionado
(extractionStep === 3 && selection.fields.length > 0);
// Auto-indexar la carpeta del job al montar (usada como muestra para descubrir campos)
useEffect(() => {
const folder = form.localRoot || form.selectedLocalPath;
if (!folder || manifest.length > 0) return;
indexFolderAt(folder, form.mode, form.docTypes)
.then((entries) => {
setManifest(entries);
// Asegurar que selectedLocalPath esté sincronizado
setForm((cur) => ({ ...cur, selectedLocalPath: folder, selectedContainerPath: folder }));
})
.catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function updateForm(patch: Partial<ExtractionFormState>) {
setForm((cur) => {
const next = { ...cur, ...patch };
if (next.localRoot) localStorage.setItem("localRoot", next.localRoot);
if (next.containerRoot) localStorage.setItem("containerRoot", next.containerRoot);
if (next.model) localStorage.setItem("extractorModel", next.model);
return next;
});
}
async function indexFolderAt(
path: string,
mode: ExtractionFormState["mode"],
docTypes: DocumentType[],
): Promise<ManifestEntry[]> {
if (!window.desktopApi) throw new Error("La selección de carpetas requiere Electron.");
const indexed = await window.desktopApi.indexFolder(path, mode, validDocTypes(docTypes), []);
if (indexed.length === 0) {
throw new Error("No se encontraron documentos compatibles en la carpeta.");
}
return indexed;
}
function resetSchemaState() {
setSchemaResponse(null);
setSelection({ fields: [], aliasMap: {} });
setEstimateJson(null);
setMaxReachedStep(1);
if (extractionStep > 1) setExtractionStep(1);
}
async function selectRoot() {
if (!window.desktopApi) { setError("La selección de carpetas requiere Electron."); return; }
setError(null);
const selected = await window.desktopApi.selectDirectory();
if (!selected) return;
try {
const indexed = await indexFolderAt(selected, form.mode, form.docTypes);
setManifest(indexed);
updateForm({ localRoot: selected, selectedLocalPath: selected, selectedContainerPath: selected });
resetSchemaState();
setMaxReachedStep(1);
setStatusText(`${indexed.length} documento(s) listos`);
// Actualizar carpeta del job
try { await client.updateJob(job.id, { folder_path: selected }); } catch { /* ignore */ }
} catch (err) {
setManifest([]);
setError(err instanceof Error ? err.message : "No se pudo indexar la carpeta");
}
}
async function handleModeChange(mode: ExtractionFormState["mode"]) {
if (mode === form.mode) return;
updateForm({ mode });
if (!form.selectedLocalPath) return;
try {
const indexed = await indexFolderAt(form.selectedLocalPath, mode, form.docTypes);
setManifest(indexed);
resetSchemaState();
setStatusText(`${indexed.length} documento(s) indexados`);
} catch (err) {
setManifest([]);
setError(err instanceof Error ? err.message : "No se pudo reindexar la carpeta");
}
}
async function discoverSchema() {
await runTask("Descubriendo campos...", async () => {
if (!window.desktopApi) throw new Error("Requiere Electron.");
if (manifest.length === 0) throw new Error("Selecciona una carpeta con documentos.");
const responses: SchemaResponse[] = [];
if (form.mode === "general") {
const entry = manifest[0];
const fileData = await window.desktopApi.readFile(entry.localPath);
const blob = new Blob([fileData.data]);
responses.push(await client.discoverSchemaUpload(buildSchemaUploadForm(form, blob, fileData.filename)));
} else {
for (const docType of validDocTypes(form.docTypes)) {
const entry = manifest.find((item) => item.docType === docType.name);
if (!entry) throw new Error(`No se encontró muestra para ${docType.name}.`);
const fileData = await window.desktopApi.readFile(entry.localPath);
const blob = new Blob([fileData.data]);
responses.push(
await client.discoverSchemaUpload(buildSchemaUploadForm(form, blob, fileData.filename, docType.name)),
);
}
}
const schema = mergeSchemaResponses(responses);
setSchemaResponse(schema);
const flat = flattenSchema(schema);
setSelection({
fields: [],
aliasMap: Object.fromEntries(Object.keys(flat.schema).map((p) => [p, defaultAlias(p)])),
});
setEstimateJson(null);
setStatusText(`${Object.keys(flat.schema).length} campo(s) descubiertos`);
setMaxReachedStep((cur) => (cur < 3 ? 3 : cur));
setExtractionStep(3);
});
}
async function estimate() {
await runTask("Estimando costo...", async () => {
if (!window.desktopApi) throw new Error("Requiere Electron.");
if (!selection.fields.length) throw new Error("Selecciona al menos un campo.");
if (manifest.length === 0) throw new Error("Selecciona una carpeta.");
const sampleEntry = manifest[0];
const fileData = await window.desktopApi.readFile(sampleEntry.localPath);
const blob = new Blob([fileData.data]);
const uniqueRadicados = new Set(manifest.map((e) => e.radicado)).size;
const formData = buildEstimateUploadForm(form, blob, fileData.filename, uniqueRadicados, selection.fields, form.model);
const result = await client.estimateUpload(formData);
setEstimateJson(result);
setStatusText("Estimación completada");
});
}
/** Determina si un archivo del conteo es válido según los filtros activos */
function isCountFileValid(f: CountJobFile): boolean {
if (f.status !== "ok" || f.is_valid === false) return false;
if (f.is_valid === true) return true;
if (!validCounts || validCounts.length === 0) return true;
return validCounts.includes(f.service_count ?? 0);
}
async function runExtraction() {
await runTask("Iniciando extracción...", async () => {
if (selection.fields.length === 0) throw new Error("Selecciona al menos un campo.");
if (!window.desktopApi) throw new Error("Requiere Electron.");
if (manifest.length === 0) throw new Error("Selecciona una carpeta.");
let workManifest: ManifestEntry[];
// ── Caso 1: desde conteo previo — filtrar manifest a los archivos válidos ──
if (countingTaskId) {
const countFiles = await client.countJobFiles(countingTaskId);
const validFilenames = new Set(
countFiles.filter(isCountFileValid).map((f) => f.filename),
);
workManifest = manifest.filter((e) => validFilenames.has(e.filename));
if (workManifest.length === 0) {
throw new Error(
"No se encontraron archivos válidos del conteo en la carpeta. " +
"Asegúrate de que los PDFs válidos están en la carpeta seleccionada.",
);
}
} else {
// ── Caso 2: extracción desktop normal ──
workManifest = [...manifest];
if (form.filterEnabled) {
const allowed = new Set(splitRadicados(form.radicadoFilterText));
workManifest = workManifest.filter((e) => allowed.has(e.radicado));
}
if (workManifest.length === 0) throw new Error("El filtro no dejó documentos.");
}
const payload = buildDesktopBatchPayload(form, selection, workManifest, form.model);
const payloadWithJob = { ...payload, job_id: job.id };
const task = await client.createExtractionTask(
payloadWithJob as Parameters<typeof client.createExtractionTask>[0],
);
setTaskCreated(task);
const total = workManifest.length;
setIngestProgress({ processed: 0, total });
onProgress?.({
module: "extraction",
total,
processed: 0,
failed: 0,
currentFile: "",
running: true,
label: "Preparando envío de documentos…",
});
const BATCH_SIZE = 20;
const BATCH_CONCURRENCY = 3;
const batches: ManifestEntry[][] = [];
for (let i = 0; i < workManifest.length; i += BATCH_SIZE) {
batches.push(workManifest.slice(i, i + BATCH_SIZE));
}
let queued = 0;
let nextBatchIdx = 0;
async function uploadBatch(batch: ManifestEntry[], batchIdx: number) {
const files = [];
for (let fi = 0; fi < batch.length; fi++) {
onProgress?.({
module: "extraction",
total,
processed: queued + fi,
failed: 0,
currentFile: batch[fi].filename,
running: true,
label: `Subiendo archivos (${queued + fi}/${total})…`,
});
files.push(await window.desktopApi!.readFile(batch[fi].localPath));
}
const blobs = files.map((f) => new Blob([f.data]));
onProgress?.({
module: "extraction",
total,
processed: queued,
failed: 0,
currentFile: batch[0]?.filename ?? "",
running: true,
label: `Enviando lote ${batchIdx + 1}/${batches.length} al servidor…`,
});
await client.ingestTaskDocumentsBulk(task.id, buildIngestFormBulk(batch, blobs));
queued += batch.length;
setIngestProgress({ processed: queued, total });
setStatusText(`Enviando ${queued}/${total} documentos…`);
onProgress?.({
module: "extraction",
total,
processed: queued,
failed: 0,
currentFile: batch[batch.length - 1]?.filename ?? "",
running: true,
label: `Subiendo archivos (${queued}/${total})…`,
});
}
async function uploadWorker() {
while (nextBatchIdx < batches.length) {
const batchIdx = nextBatchIdx++;
await uploadBatch(batches[batchIdx], batchIdx);
}
}
await Promise.all(
Array.from({ length: Math.min(BATCH_CONCURRENCY, batches.length) }, () => uploadWorker()),
);
setIngestProgress(null);
setStatusText(`${total} documentos enviados. Procesando en segundo plano…`);
onProgress?.({
module: "extraction",
total,
processed: total,
failed: 0,
currentFile: "",
running: false,
label: "Subida completada — extrayendo datos…",
});
onTaskCreated(task);
});
}
async function runTask(label: string, fn: () => Promise<void>) {
setBusy(true);
setError(null);
setStatusText(label);
try {
await fn();
} catch (err) {
setError(err instanceof Error ? err.message : "Error desconocido");
setStatusText("Error");
} finally {
setBusy(false);
}
}
function goToStep(step: ExtractionStep) {
setExtractionStep(step);
setMaxReachedStep((cur) => (step > cur ? step : cur));
}
function setDocType(index: number, patch: Partial<DocumentType>) {
updateForm({
docTypes: form.docTypes.map((item, idx) => (idx === index ? { ...item, ...patch } : item)),
});
}
function toggleField(path: string, checked: boolean) {
setSelection((cur) => {
const fields = checked
? Array.from(new Set([...cur.fields, path]))
: cur.fields.filter((f) => f !== path);
const aliasMap = { ...cur.aliasMap };
if (checked && !aliasMap[path]) aliasMap[path] = defaultAlias(path);
return { fields, aliasMap };
});
}
function selectAllFields() {
setSelection({
fields: Object.keys(flattened.schema),
aliasMap: Object.fromEntries(
Object.keys(flattened.schema).map((p) => [p, selection.aliasMap[p] || defaultAlias(p)]),
),
});
}
// Si ya se creó tarea, muestra mensaje y el módulo Results tomará el relevo
if (taskCreated) {
return (
<div className="module-loading" style={{ padding: 48 }}>
<p style={{ fontSize: 15, fontWeight: 600, margin: 0 }}>✓ Extracción iniciada</p>
<p style={{ color: "var(--text-muted)", margin: 0 }}>
Los resultados aparecerán automáticamente en esta pestaña.
</p>
</div>
);
}
return (
<div className="job-extraction-wrapper">
{/* ── Banner "desde conteo" — solo cuando viene de archivos válidos del conteo ── */}
{countingTaskId && (
<div className="from-count-info-banner">
<CheckCircle2 size={15} />
<span>
<strong>Extracción desde conteo</strong> — solo se procesarán los{" "}
<strong>{validCount ?? "…"} archivos válidos</strong> seleccionados. La carpeta se usa
únicamente para descubrir los campos del documento.
</span>
<ArrowRight size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
<span className="from-count-info-files">
<FileText size={13} />
{validCount ?? "…"} archivos
</span>
</div>
)}
<ExtractionWizard
step={extractionStep}
maxReached={maxReachedStep}
onStepChange={goToStep}
form={form}
updateForm={updateForm}
onModeChange={handleModeChange}
busy={busy}
error={error}
statusText={statusText}
selectRoot={selectRoot}
discoverSchema={discoverSchema}
estimate={estimate}
runExtraction={runExtraction}
selection={selection}
setSelection={setSelection}
schemaSearch={schemaSearch}
setSchemaSearch={setSchemaSearch}
flattened={flattened}
schemaResponse={schemaResponse}
estimateJson={estimateJson}
manifest={manifest}
ingestProgress={ingestProgress}
setDocType={setDocType}
toggleField={toggleField}
selectAllFields={selectAllFields}
canGoNext={canGoNext}
onBack={() => goToStep((extractionStep > 1 ? extractionStep - 1 : 1) as ExtractionStep)}
onNext={() => { if (canGoNext && extractionStep < 4) goToStep((extractionStep + 1) as ExtractionStep); }}
effectiveDocumentCount={countingTaskId ? validCount : undefined}
documentSourceLabel={countingTaskId ? "válidos del conteo" : undefined}
/>
</div>
);
}
@@ -0,0 +1,11 @@
import { JobExtractionWizard } from "./JobExtractionWizard";
import type { ModuleWizardProps } from "../registry";
/**
* Punto de entrada del módulo de extracción para el sistema de JobView.
* Pasa el contexto de activación (p.ej. countingTaskId desde el módulo de conteo)
* al wizard original completo.
*/
export function JobExtractionWizardModule(props: ModuleWizardProps) {
return <JobExtractionWizard {...props} />;
}
@@ -0,0 +1,50 @@
import type React from "react";
import type { ApiClient } from "../lib/apiClient";
import type { Job, Task } from "../../shared/types";
export interface ModuleWizardProps {
client: ApiClient;
job: Job;
onTaskCreated: (task: Task) => void;
/** Señal para activar otro módulo, opcionalmente con contexto */
onActivateModule?: (type: string, context?: Record<string, unknown>) => void;
/** Contexto pasado al activar este módulo desde otro */
context?: Record<string, unknown>;
}
export interface ModuleResultsProps {
client: ApiClient;
task: Task;
job: Job;
onTaskUpdated: (task: Task) => void;
/** Señal para activar otro módulo */
onActivateModule?: (type: string, context?: Record<string, unknown>) => void;
}
export interface ModuleDescriptor {
/** Valor único que coincide con task.type en la BD */
type: string;
label: string;
/** Icono de lucide-react */
iconName: string;
/** True si ya existe una tarea de este módulo en el job */
hasTask: (tasks: Task[]) => boolean;
/** Componente para ejecutar el módulo (wizard de creación) */
Wizard: React.FC<ModuleWizardProps>;
/** Componente para ver resultados de una tarea completada */
Results: React.FC<ModuleResultsProps>;
}
// El registro se puebla en runtime para evitar importaciones circulares.
// Cada módulo se registra desde su propio archivo.
const _registry: ModuleDescriptor[] = [];
export function registerModule(descriptor: ModuleDescriptor): void {
if (!_registry.find((m) => m.type === descriptor.type)) {
_registry.push(descriptor);
}
}
export function getRegistry(): ModuleDescriptor[] {
return _registry;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
export type JobSection = "counting" | "extraction" | "results";
@@ -0,0 +1,255 @@
import { Briefcase, CalendarDays, ChevronRight, FolderOpen, Loader2, Plus, RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import type { ApiClient } from "../lib/apiClient";
import { DEFAULT_API_BASE_URL } from "../lib/apiDefaults";
import type { Job } from "../../shared/types";
declare const desktopApi: import("../../shared/types").DesktopBridge;
interface Props {
client: ApiClient;
onOpenJob: (job: Job) => void;
}
function formatRelative(iso: string): string {
const d = new Date(iso);
const now = Date.now();
const diff = now - d.getTime();
if (diff < 60_000) return "hace un momento";
if (diff < 3_600_000) return `hace ${Math.floor(diff / 60_000)} min`;
if (diff < 86_400_000) return `hace ${Math.floor(diff / 3_600_000)} h`;
return d.toLocaleDateString("es-CO", { day: "2-digit", month: "short", year: "numeric" });
}
function statusLabel(s: string) {
switch (s) {
case "pending": return "Pendiente";
case "running": return "En progreso";
case "completed": return "Completado";
case "failed": return "Error";
default: return s;
}
}
export function HomeView({ client, onOpenJob }: Props) {
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [formTitle, setFormTitle] = useState("");
const [formFolder, setFormFolder] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const loadJobs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await client.jobs();
setJobs([...data].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setError(
msg.includes("conectar") || msg.includes("Tiempo de espera")
? msg
: `${msg}. Verifica Docker y que el gateway use ${DEFAULT_API_BASE_URL}`,
);
setJobs([]);
} finally {
setLoading(false);
}
}, [client]);
useEffect(() => { loadJobs(); }, [loadJobs]);
useEffect(() => {
if (!showForm) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = prev; };
}, [showForm]);
async function selectFolder() {
const dir = await desktopApi.selectDirectory();
if (dir) {
setFormFolder(dir);
if (!formTitle) {
// Usar último segmento del path como título sugerido
const parts = dir.replace(/\\/g, "/").split("/").filter(Boolean);
setFormTitle(parts[parts.length - 1] ?? "");
}
}
}
async function handleCreate() {
if (!formTitle.trim()) { setCreateError("Ingresa un nombre para el trabajo."); return; }
setCreating(true);
setCreateError(null);
try {
const job = await client.createJob({
title: formTitle.trim(),
folder_path: formFolder || undefined,
});
setShowForm(false);
setFormTitle("");
setFormFolder("");
onOpenJob(job);
} catch (e) {
setCreateError(String(e));
} finally {
setCreating(false);
}
}
return (
<div className="home-view">
<header className="home-header">
<div>
<h1 className="home-title">Trabajos</h1>
<p className="home-subtitle">Cada trabajo agrupa conteo y extracción de un lote de documentos.</p>
</div>
<button type="button" className="btn btn-primary" onClick={() => setShowForm(true)}>
<Plus size={15} /> Nuevo trabajo
</button>
</header>
{/* ── Modal crear trabajo (portal: evita que fixed quede anclado a .home-view) ── */}
{showForm && createPortal(
<div className="modal-backdrop" onClick={() => setShowForm(false)}>
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Nuevo trabajo</h2>
<button
type="button"
className="modal-close"
onClick={() => setShowForm(false)}
aria-label="Cerrar"
>
<X size={16} />
</button>
</div>
<div className="modal-body">
<div className="form-field">
<label className="form-label" htmlFor="job-title">Nombre del trabajo</label>
<input
id="job-title"
className="form-input"
type="text"
placeholder="Ej: Lote Julio 2026"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
autoFocus
/>
</div>
<div className="form-field">
<label className="form-label">Carpeta de documentos (opcional)</label>
<div className="folder-picker">
<div className="folder-picker-display">
{formFolder ? (
<>
<FolderOpen size={15} className="folder-picker-icon" />
<span className="folder-picker-path">{formFolder}</span>
</>
) : (
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span>
)}
</div>
<button type="button" className="btn btn-sm" onClick={selectFolder}>
<FolderOpen size={14} /> Explorar
</button>
</div>
<span className="form-hint">Se puede configurar después dentro del trabajo.</span>
</div>
{createError && (
<div className="wiz-toast wiz-toast--error">{createError}</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={() => setShowForm(false)}>
Cancelar
</button>
<button
type="button"
className="btn btn-primary"
onClick={handleCreate}
disabled={creating || !formTitle.trim()}
>
{creating ? <Loader2 size={14} className="spin" /> : <Plus size={14} />}
Crear trabajo
</button>
</div>
</div>
</div>,
document.body,
)}
{/* ── Lista de trabajos ── */}
<div className="home-body">
{loading ? (
<div className="home-loading">
<Loader2 size={22} className="spin" />
<span>Cargando trabajos…</span>
</div>
) : error ? (
<div className="home-empty">
<div className="wiz-toast wiz-toast--error" style={{ maxWidth: 480, textAlign: "left" }}>
{error}
</div>
<button type="button" className="btn btn-primary" onClick={() => loadJobs()}>
<RefreshCw size={14} /> Reintentar
</button>
</div>
) : jobs.length === 0 ? (
<div className="home-empty">
<Briefcase size={40} style={{ opacity: 0.3 }} />
<p>No hay trabajos todavía.</p>
<button type="button" className="btn btn-primary" onClick={() => setShowForm(true)}>
<Plus size={14} /> Crear primer trabajo
</button>
</div>
) : (
<ul className="job-list">
{jobs.map((job) => (
<li key={job.id}>
<button
type="button"
className="job-card"
onClick={() => onOpenJob(job)}
>
<div className="job-card-icon">
<Briefcase size={20} />
</div>
<div className="job-card-body">
<div className="job-card-title">{job.title ?? "Sin título"}</div>
{job.metadata?.folder_path != null && (
<div className="job-card-path">
<FolderOpen size={12} />
{String(job.metadata.folder_path)}
</div>
)}
<div className="job-card-meta">
<span className={`task-badge task-badge--${job.status}`}>
{statusLabel(job.status)}
</span>
<span className="job-card-date">
<CalendarDays size={12} />
{formatRelative(job.updated_at)}
</span>
</div>
</div>
<ChevronRight size={18} className="job-card-arrow" />
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
@@ -0,0 +1,494 @@
import {
AlertCircle,
ArrowRight,
BarChart2,
Cpu,
Download,
Hash,
Loader2,
Plus,
RefreshCw,
} from "lucide-react";
import { useEffect, useState } from "react";
import type { BackgroundProgress, CountJobFile, Job, Task } from "../../shared/types";
import { CountResultsTable } from "../components/CountResultsTable";
import { BackgroundProgressBanner } from "../components/BackgroundProgressBanner";
import type { ApiClient } from "../lib/apiClient";
type ResultTab = "counting" | "extraction";
interface Props {
client: ApiClient;
job: Job;
tasks: Task[];
countingProgress?: BackgroundProgress | null;
extractionProgress?: BackgroundProgress | null;
onRefresh: () => Promise<void>;
onStartCounting: () => void;
onStartExtraction: (ctx?: Record<string, unknown>) => void;
}
export function JobResultsView({
client,
job,
tasks,
countingProgress,
extractionProgress,
onRefresh,
onStartCounting,
onStartExtraction,
}: Props) {
const [activeTab, setActiveTab] = useState<ResultTab>("counting");
const [refreshing, setRefreshing] = useState(false);
const countingTasks = tasks.filter((t) => t.type === "counting")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
const extractionTasks = tasks.filter((t) => t.type === "extraction")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
async function refresh() {
setRefreshing(true);
await onRefresh();
setRefreshing(false);
}
return (
<div className="job-results-view">
{/* ── Sub-tabs: Conteo | Extracción ── */}
<div className="job-results-tabs">
<button
type="button"
className={`job-results-tab${activeTab === "counting" ? " job-results-tab--active" : ""}`}
onClick={() => setActiveTab("counting")}
>
<Hash size={15} />
Conteo
{(countingTasks.length > 0 || countingProgress?.running) && (
<span className={`job-results-tab-count${countingProgress?.running ? " job-results-tab-count--live" : ""}`}>
{countingProgress?.running
? `${countingProgress.processed}/${countingProgress.total}`
: countingTasks.length}
</span>
)}
</button>
<button
type="button"
className={`job-results-tab${activeTab === "extraction" ? " job-results-tab--active" : ""}`}
onClick={() => setActiveTab("extraction")}
>
<Cpu size={15} />
Extracción
{(extractionTasks.length > 0 || extractionProgress?.running) && (
<span className={`job-results-tab-count${extractionProgress?.running ? " job-results-tab-count--live" : ""}`}>
{extractionProgress?.running
? `${extractionProgress.processed}/${extractionProgress.total}`
: extractionTasks.length}
</span>
)}
</button>
<div style={{ flex: 1 }} />
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={refresh}
disabled={refreshing}
title="Actualizar resultados"
>
<RefreshCw size={13} className={refreshing ? "spin" : ""} />
</button>
</div>
{/* ── Contenido ── */}
<div className="job-results-content">
{activeTab === "counting" && (
<CountingResultsSection
client={client}
tasks={countingTasks}
liveProgress={countingProgress}
onStartCounting={onStartCounting}
onExtractFromCount={(countingTaskId, validCounts, validCount) =>
onStartExtraction({ countingTaskId, validCounts, validCount })
}
/>
)}
{activeTab === "extraction" && (
<ExtractionResultsSection
client={client}
tasks={extractionTasks}
liveProgress={extractionProgress}
onStartExtraction={() => onStartExtraction()}
/>
)}
</div>
</div>
);
}
/* ══════════════════════════════════════════
Sección de resultados de conteo
══════════════════════════════════════════ */
function CountingResultsSection({
client,
tasks,
liveProgress,
onStartCounting,
onExtractFromCount,
}: {
client: ApiClient;
tasks: Task[];
liveProgress?: BackgroundProgress | null;
onStartCounting: () => void;
onExtractFromCount: (taskId: string, validCounts: number[], validCount: number) => void;
}) {
const [selectedTask, setSelectedTask] = useState<Task | null>(tasks[0] ?? null);
const [files, setFiles] = useState<CountJobFile[]>([]);
const [loadingFiles, setLoadingFiles] = useState(false);
const [validCounts, setValidCounts] = useState<number[]>([1]);
const [autoAdjustedFilenames] = useState<Set<string>>(new Set());
useEffect(() => {
setSelectedTask(tasks[0] ?? null);
}, [tasks.map((t) => t.id).join(",")]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!selectedTask) { setFiles([]); return; }
setLoadingFiles(true);
client.countJobFiles(selectedTask.id)
.then(setFiles)
.catch(() => setFiles([]))
.finally(() => setLoadingFiles(false));
}, [selectedTask?.id]); // eslint-disable-line react-hooks/exhaustive-deps
function isFileValid(f: CountJobFile): boolean {
if (f.status !== "ok" || f.is_valid === false) return false;
if (f.is_valid === true) return true;
if (validCounts.length === 0) return true;
return validCounts.includes(f.service_count ?? 0);
}
async function handleToggleValid(file: CountJobFile, isValid: boolean | null) {
if (!selectedTask) return;
const updated = await client.markCountFileValid(selectedTask.id, file.id, isValid);
setFiles((prev) => prev.map((f) => (f.id === updated.id ? updated : f)));
}
function exportCsv(subset: "valid" | "invalid") {
const subset_files = subset === "valid" ? files.filter(isFileValid) : files.filter((f) => !isFileValid(f));
const headers = ["filename", "radicado", "service_count", "status", "is_valid", "error"];
const rows = subset_files.map((f) =>
[`"${f.filename}"`, `"${f.radicado ?? ""}"`, f.service_count ?? "", f.status, f.is_valid === null ? "" : String(f.is_valid), `"${(f.error ?? "").replace(/"/g, '""')}"`].join(","),
);
const csv = [headers.join(","), ...rows].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `conteo-${subset}-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
if (tasks.length === 0 && !liveProgress?.running) {
return (
<div className="job-results-empty">
<BarChart2 size={36} style={{ opacity: 0.3 }} />
<p>No hay resultados de conteo todavía.</p>
<button type="button" className="btn btn-primary" onClick={onStartCounting}>
<Plus size={14} /> Iniciar conteo
</button>
</div>
);
}
if (tasks.length === 0 && liveProgress?.running) {
return (
<div className="results-section results-section--live-only">
<BackgroundProgressBanner progress={liveProgress} />
<p className="results-live-hint">
El conteo está en curso. Los resultados aparecerán aquí al terminar.
Puedes navegar libremente por la app.
</p>
</div>
);
}
const validCount = files.filter(isFileValid).length;
return (
<div className="results-section">
{liveProgress?.running && (
<div className="results-live-progress">
<BackgroundProgressBanner progress={liveProgress} />
<p className="results-live-hint">
Puedes cambiar de sección; el conteo continúa en segundo plano.
</p>
</div>
)}
{/* Selector de ejecución + acciones */}
<div className="results-section-header">
<div className="results-task-selector">
{tasks.length > 1 && (
<select
className="form-input"
style={{ width: "auto", fontSize: 13 }}
value={selectedTask?.id ?? ""}
onChange={(e) => setSelectedTask(tasks.find((t) => t.id === e.target.value) ?? null)}
>
{tasks.map((t, i) => (
<option key={t.id} value={t.id}>
{t.title ?? `Conteo ${tasks.length - i}`} — {new Date(t.created_at).toLocaleDateString("es-CO")}
</option>
))}
</select>
)}
{selectedTask && (
<span className={`task-badge task-badge--${selectedTask.status}`}>{selectedTask.status}</span>
)}
</div>
<div className="results-section-actions">
<button type="button" className="btn btn-sm btn-ghost" onClick={() => exportCsv("valid")} disabled={validCount === 0}>
<Download size={13} /> CSV válidos ({validCount})
</button>
<button type="button" className="btn btn-sm btn-ghost" onClick={() => exportCsv("invalid")} disabled={files.filter((f) => !isFileValid(f)).length === 0}>
<Download size={13} /> CSV inválidos
</button>
<button type="button" className="btn btn-sm btn-ghost" onClick={onStartCounting}>
<Plus size={13} /> Nuevo conteo
</button>
{selectedTask && validCount > 0 && (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => onExtractFromCount(selectedTask.id, validCounts, validCount)}
>
<ArrowRight size={14} /> Extraer válidos ({validCount})
</button>
)}
</div>
</div>
{loadingFiles ? (
<div className="module-loading"><Loader2 size={20} className="spin" /></div>
) : (
<CountResultsTable
files={files}
validCounts={validCounts}
onValidCountsChange={setValidCounts}
autoAdjustedFilenames={autoAdjustedFilenames}
onToggleValid={handleToggleValid}
loading={false}
/>
)}
</div>
);
}
/* ══════════════════════════════════════════
Sección de resultados de extracción
══════════════════════════════════════════ */
type Row = Record<string, unknown>;
function ExtractionResultsSection({
client,
tasks,
liveProgress,
onStartExtraction,
}: {
client: ApiClient;
tasks: Task[];
liveProgress?: BackgroundProgress | null;
onStartExtraction: () => void;
}) {
const [selectedTask, setSelectedTask] = useState<Task | null>(tasks[0] ?? null);
const [rows, setRows] = useState<Row[]>([]);
const [columns, setColumns] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [taskStatus, setTaskStatus] = useState<string>("pending");
useEffect(() => {
setSelectedTask(tasks[0] ?? null);
}, [tasks.map((t) => t.id).join(",")]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!selectedTask) { setRows([]); setColumns([]); return; }
let cancelled = false;
async function poll() {
setLoading(true);
setError(null);
try {
const t = await client.task(selectedTask!.id);
if (cancelled) return;
setTaskStatus(t.status);
if (t.status === "running" || t.status === "pending") {
setTimeout(poll, 4000);
return;
}
const resp = await client.taskRows(t.id, 500, 0);
if (cancelled) return;
const data = resp.rows ?? [];
setRows(data);
const cols = Array.from(new Set(data.flatMap((r: Row) => Object.keys(r)))).filter(
(c) => !["_id", "batch_id"].includes(c),
) as string[];
setColumns(cols);
} catch (e) {
if (!cancelled) setError(String(e));
} finally {
if (!cancelled) setLoading(false);
}
}
poll();
return () => { cancelled = true; };
}, [selectedTask?.id]); // eslint-disable-line react-hooks/exhaustive-deps
function exportCsv() {
const csv = [columns.join(","), ...rows.map((r) =>
columns.map((c) => {
const v = r[c];
if (v === null || v === undefined) return "";
return `"${String(v).replace(/"/g, '""')}"`;
}).join(","),
)].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `extraccion-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
if (tasks.length === 0 && !liveProgress?.running) {
return (
<div className="job-results-empty">
<BarChart2 size={36} style={{ opacity: 0.3 }} />
<p>No hay resultados de extracción todavía.</p>
<button type="button" className="btn btn-primary" onClick={onStartExtraction}>
<Plus size={14} /> Iniciar extracción
</button>
</div>
);
}
if (tasks.length === 0 && liveProgress?.running) {
return (
<div className="results-section results-section--live-only">
<BackgroundProgressBanner progress={liveProgress} />
<p className="results-live-hint">
Subiendo documentos. Puedes cambiar de sección; la carga continúa en segundo plano.
</p>
</div>
);
}
const isRunning = taskStatus === "running" || taskStatus === "pending";
return (
<div className="results-section">
{liveProgress?.running && (
<div className="results-live-progress">
<BackgroundProgressBanner progress={liveProgress} />
</div>
)}
<div className="results-section-header">
<div className="results-task-selector">
{tasks.length > 1 && (
<select
className="form-input"
style={{ width: "auto", fontSize: 13 }}
value={selectedTask?.id ?? ""}
onChange={(e) => setSelectedTask(tasks.find((t) => t.id === e.target.value) ?? null)}
>
{tasks.map((t, i) => (
<option key={t.id} value={t.id}>
{t.title ?? `Extracción ${tasks.length - i}`} — {new Date(t.created_at).toLocaleDateString("es-CO")}
</option>
))}
</select>
)}
{selectedTask && (
<span className={`task-badge task-badge--${selectedTask.status}`}>
{isRunning && <Loader2 size={11} className="spin" />}
{taskStatus}
</span>
)}
</div>
<div className="results-section-actions">
{rows.length > 0 && (
<button type="button" className="btn btn-sm btn-ghost" onClick={exportCsv}>
<Download size={13} /> CSV ({rows.length} filas)
</button>
)}
<button type="button" className="btn btn-sm btn-ghost" onClick={onStartExtraction}>
<Plus size={13} /> Nueva extracción
</button>
</div>
</div>
{/* Error de red / API */}
{error && (
<div className="wiz-toast wiz-toast--error" style={{ margin: "12px 16px" }}>
<AlertCircle size={14} /> {error}
</div>
)}
{/* Error de la tarea (ej: archivos no encontrados) */}
{!error && selectedTask?.status === "failed" && selectedTask.error && (
<div className="wiz-toast wiz-toast--error" style={{ margin: "12px 16px" }}>
<AlertCircle size={14} /> La extracción falló: {selectedTask.error}
</div>
)}
{/* Advertencia si la tarea completó pero hay errores parciales */}
{!error && selectedTask?.status === "completed" && rows.length === 0 && (
<div className="wiz-toast wiz-toast--warn" style={{ margin: "12px 16px" }}>
<AlertCircle size={14} />
La tarea completó sin filas. Si iniciaste esta extracción desde conteo,
relanza la extracción — los archivos ahora se suben directamente desde el desktop.
</div>
)}
{loading && rows.length === 0 ? (
<div className="module-loading"><Loader2 size={20} className="spin" /></div>
) : isRunning ? (
<div className="module-loading" style={{ flexDirection: "column", gap: 12, padding: 40 }}>
<Loader2 size={24} className="spin" />
<p style={{ margin: 0, color: "var(--text-muted)" }}>Extracción en progreso…</p>
<div className="wiz-progress" style={{ width: 280 }}>
<div className="wiz-progress-track">
<div className="wiz-progress-fill" style={{ width: "100%", animation: "indeterminate 1.4s ease infinite" }} />
</div>
</div>
</div>
) : rows.length === 0 ? (
<div className="job-results-empty" style={{ padding: 40 }}>
<p>No hay filas extraídas.</p>
</div>
) : (
<div style={{ overflowX: "auto", flex: 1 }}>
<table className="results-table">
<thead>
<tr>{columns.map((c) => <th key={c}>{c}</th>)}</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
{columns.map((c) => (
<td key={c}>
{row[c] === null || row[c] === undefined
? <span style={{ color: "var(--text-muted)" }}>—</span>
: String(row[c])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,179 @@
import { Loader2, Pencil, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ApiClient } from "../lib/apiClient";
import type { BackgroundProgress, Job, Task } from "../../shared/types";
import type { JobSection } from "../types/jobSection";
import { BackgroundProgressBanner } from "../components/BackgroundProgressBanner";
import { CountingWizardModule } from "../modules/counting/CountingWizardModule";
import { JobExtractionWizard } from "../modules/extraction/JobExtractionWizard";
import { JobResultsView } from "./JobResultsView";
interface Props {
client: ApiClient;
job: Job;
section: JobSection;
sectionContext: Record<string, unknown>;
onActivateSection: (section: JobSection, context?: Record<string, unknown>) => void;
onJobUpdated: (job: Job) => void;
}
export function JobView({ client, job, section, sectionContext, onActivateSection, onJobUpdated }: Props) {
const [tasks, setTasks] = useState<Task[]>([]);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(job.title ?? "");
const [savingTitle, setSavingTitle] = useState(false);
const [countingProgress, setCountingProgress] = useState<BackgroundProgress | null>(null);
const [extractionProgress, setExtractionProgress] = useState<BackgroundProgress | null>(null);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const backgroundRunning =
countingProgress?.running === true || extractionProgress?.running === true;
const loadTasks = useCallback(async () => {
try {
const data = await client.jobTasks(job.id);
setTasks(data);
} catch { /* silent */ }
}, [client, job.id]);
useEffect(() => {
loadTasks();
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
}, [loadTasks]);
// Polling más frecuente mientras hay trabajo en segundo plano
useEffect(() => {
if (pollingRef.current) clearInterval(pollingRef.current);
const intervalMs = backgroundRunning ? 3000 : 8000;
pollingRef.current = setInterval(loadTasks, intervalMs);
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
}, [backgroundRunning, loadTasks]);
function handleTaskCreated(task: Task) {
setTasks((prev) => {
const idx = prev.findIndex((t) => t.id === task.id);
return idx >= 0
? prev.map((t, i) => (i === idx ? task : t))
: [...prev, task];
});
loadTasks();
}
async function saveTitle() {
if (!titleDraft.trim() || titleDraft === job.title) { setEditingTitle(false); return; }
setSavingTitle(true);
try {
const updated = await client.updateJob(job.id, { title: titleDraft.trim() });
onJobUpdated(updated);
} finally {
setSavingTitle(false);
setEditingTitle(false);
}
}
const activeBackground =
countingProgress?.running ? countingProgress
: extractionProgress?.running ? extractionProgress
: null;
return (
<div className="job-view">
<header className="job-content-header">
<div className="job-title-area">
{editingTitle ? (
<div className="job-title-edit">
<input
className="form-input job-title-input"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveTitle();
if (e.key === "Escape") setEditingTitle(false);
}}
autoFocus
/>
<button type="button" className="btn btn-sm btn-primary" onClick={saveTitle} disabled={savingTitle}>
{savingTitle ? <Loader2 size={13} className="spin" /> : "Guardar"}
</button>
<button type="button" className="btn btn-sm" onClick={() => setEditingTitle(false)}>
<X size={13} />
</button>
</div>
) : (
<button
type="button"
className="job-title-btn"
onClick={() => { setTitleDraft(job.title ?? ""); setEditingTitle(true); }}
title="Editar nombre del trabajo"
>
<h1 className="job-title">{job.title ?? "Sin título"}</h1>
<Pencil size={13} className="job-title-edit-icon" />
</button>
)}
</div>
<div className="job-content-header-section">
{section === "counting" && "Conteo de servicios"}
{section === "extraction" && "Extracción de datos"}
{section === "results" && "Resultados del trabajo"}
</div>
</header>
{/* Banner global visible en cualquier sección mientras corre un proceso */}
{activeBackground && section !== "results" && (
<div className="job-background-banner-wrap">
<BackgroundProgressBanner progress={activeBackground} compact />
<button
type="button"
className="btn btn-sm btn-ghost"
onClick={() => onActivateSection("results")}
>
Ver progreso en Resultados →
</button>
</div>
)}
{/*
Los módulos permanecen montados (solo ocultos) para que el conteo/subida
siga en segundo plano al cambiar de sección en el sidebar.
*/}
<div className="job-section-content">
<div className={`job-section-pane${section === "counting" ? "" : " job-section-pane--hidden"}`}>
<CountingWizardModule
client={client}
job={job}
onTaskCreated={handleTaskCreated}
onProgress={(p) => setCountingProgress(p.running ? p : null)}
onActivateModule={(type, ctx) => onActivateSection(type as JobSection, ctx)}
/>
</div>
<div className={`job-section-pane${section === "extraction" ? "" : " job-section-pane--hidden"}`}>
<JobExtractionWizard
client={client}
job={job}
onTaskCreated={handleTaskCreated}
onProgress={(p) => setExtractionProgress(p.running ? p : null)}
onActivateModule={(type, ctx) => onActivateSection(type as JobSection, ctx)}
context={sectionContext}
countingTaskId={sectionContext.countingTaskId as string | undefined}
validCounts={sectionContext.validCounts as number[] | undefined}
validCount={sectionContext.validCount as number | undefined}
/>
</div>
<div className={`job-section-pane${section === "results" ? "" : " job-section-pane--hidden"}`}>
<JobResultsView
client={client}
job={job}
tasks={tasks}
countingProgress={countingProgress}
extractionProgress={extractionProgress}
onRefresh={loadTasks}
onStartCounting={() => onActivateSection("counting")}
onStartExtraction={(ctx?: Record<string, unknown>) => onActivateSection("extraction", ctx)}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,16 @@
export const APP_VERSION: string = __APP_VERSION__;
export const APP_RELEASE_DATE: string = __APP_RELEASE_DATE__;
export function formatReleaseDate(isoDate: string): string {
const [year, month, day] = isoDate.split("-").map(Number);
const date = new Date(year, month - 1, day);
return date.toLocaleDateString("es-CO", {
day: "numeric",
month: "short",
year: "numeric",
});
}
export function formatVersionLabel(): string {
return `v${APP_VERSION} · ${formatReleaseDate(APP_RELEASE_DATE)}`;
}
+29 -1
View File
@@ -69,6 +69,7 @@ export interface Task {
result: Record<string, unknown>;
error: string | null;
owner_id: string | null;
job_id?: string | null;
created_at: string;
updated_at: string;
started_at: string | null;
@@ -92,6 +93,17 @@ export interface Job {
updated_at: string;
}
export interface CreateJobPayload {
title?: string;
folder_path?: string;
}
export interface UpdateJobPayload {
title?: string;
folder_path?: string;
status?: string;
}
export interface CountJobFile {
id: string;
job_module_id: string;
@@ -120,12 +132,15 @@ export interface CountJobFileCreateEntry {
export interface CreateCountJobPayload {
title?: string;
job_id?: string;
results: CountJobFileCreateEntry[];
}
export interface ExtractionFromCountPayload {
counting_task_id: string;
min_service_count: number;
valid_counts?: number[];
job_id?: string;
fields: string[];
alias_map: Record<string, string>;
doc_types?: Array<{ name: string; keyword: string }>;
@@ -153,7 +168,20 @@ export interface CountingFormState {
localRoot: string;
containerRoot: string;
includeRejected: boolean;
minServiceCount: number;
/** Conteos exactos válidos (ej: [1, 3] = solo facturas con 1 o 3 servicios). Vacío = todos. */
validCounts: number[];
}
/** Estado de progreso de un módulo ejecutándose en segundo plano. */
export interface BackgroundProgress {
module: "counting" | "extraction";
total: number;
processed: number;
failed: number;
currentFile: string;
running: boolean;
/** Texto descriptivo de la acción actual, ej. "Analizando…" o "Subiendo archivos…" */
label: string;
}
export interface RuntimeConfig {
+21 -2
View File
@@ -1,8 +1,24 @@
import { readFileSync } from "node:fs";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
const pkg = JSON.parse(readFileSync("./package.json", "utf8")) as { version: string };
const versionMeta = JSON.parse(
readFileSync("./src/shared/version.json", "utf8"),
) as { releaseDate: string };
const PRODUCTION_API_BASE_URL = "https://pys1.builder.xyz";
const DEVELOPMENT_API_BASE_URL = "http://127.0.0.1:8189";
export default defineConfig(({ mode }) => ({
plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
__APP_RELEASE_DATE__: JSON.stringify(versionMeta.releaseDate),
__DEFAULT_API_BASE_URL__: JSON.stringify(
mode === "production" ? PRODUCTION_API_BASE_URL : DEVELOPMENT_API_BASE_URL,
),
},
server: {
port: 5173,
strictPort: true,
@@ -11,5 +27,8 @@ export default defineConfig({
environment: "jsdom",
setupFiles: ["src/renderer/test/setup.ts"],
globals: true,
define: {
__DEFAULT_API_BASE_URL__: JSON.stringify(DEVELOPMENT_API_BASE_URL),
},
},
});
}));
+92 -14
View File
@@ -138,6 +138,8 @@ class ExtractionRequest(BaseModel):
radicado_filter: list[str] | None = None
documents: list[DesktopDocument] | None = None
document_count: int | None = None
job_id: str | None = None # vincular al job existente
model: str | None = None
@model_validator(mode="after")
def validate_source(self) -> "ExtractionRequest":
@@ -538,6 +540,7 @@ def create_extraction_task(request: ExtractionRequest, background_tasks: Backgro
session.flush()
batch_id = batch.id
task = Task(
job_id=request.job_id,
type="extraction",
status="running",
title=title,
@@ -560,6 +563,7 @@ def create_extraction_task(request: ExtractionRequest, background_tasks: Backgro
with session_scope() as session:
task = Task(
job_id=request.job_id,
type="extraction",
status="pending",
title=f"Extraccion {request.root_path}",
@@ -966,6 +970,52 @@ def serialize_job(job: Job) -> dict[str, Any]:
}
class CreateJobRequest(BaseModel):
title: str | None = None
folder_path: str | None = None
class UpdateJobRequest(BaseModel):
title: str | None = None
folder_path: str | None = None
status: str | None = None
@app.post("/jobs", response_model=JobRead)
def create_job(request: CreateJobRequest) -> dict[str, Any]:
"""Crea un trabajo (job) independiente como contenedor para módulos."""
with session_scope() as session:
job = Job(
status="pending",
title=request.title,
job_metadata={"folder_path": request.folder_path} if request.folder_path else {},
updated_at=utcnow(),
)
session.add(job)
session.flush()
return serialize_job(job)
@app.patch("/jobs/{job_id}", response_model=JobRead)
def update_job(job_id: str, request: UpdateJobRequest) -> dict[str, Any]:
"""Actualiza título, metadata o estado de un job."""
with session_scope() as session:
job = session.get(Job, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job no encontrado")
if request.title is not None:
job.title = request.title
if request.status is not None:
job.status = request.status
if request.folder_path is not None:
meta = dict(job.job_metadata or {})
meta["folder_path"] = request.folder_path
job.job_metadata = meta
job.updated_at = utcnow()
session.flush()
return serialize_job(job)
@app.get("/jobs", response_model=list[JobRead])
def list_jobs() -> list[dict[str, Any]]:
with session_scope() as session:
@@ -1011,6 +1061,7 @@ class CountJobFileCreate(BaseModel):
class CreateCountJobRequest(BaseModel):
title: str | None = None
job_id: str | None = None # si se pasa, se usa el job existente
results: list[CountJobFileCreate]
@@ -1036,6 +1087,8 @@ class UpdateCountJobFileRequest(BaseModel):
class ExtractionFromCountRequest(BaseModel):
counting_task_id: str
min_service_count: int = 0
valid_counts: list[int] | None = None # conteos exactos válidos (vacío = todos)
job_id: str | None = None # vincular al job existente del conteo
fields: list[str] = Field(default_factory=list)
alias_map: dict[str, str] = Field(default_factory=dict)
doc_types: list[DocumentType] | None = None
@@ -1067,20 +1120,30 @@ def serialize_count_file(f: CountJobFile) -> dict[str, Any]:
def create_count_job(request: CreateCountJobRequest) -> dict[str, Any]:
"""Persiste los resultados del servicio de conteo de facturas en la BD compartida."""
total = len(request.results)
default_title = request.title or f"Conteo ({total} archivos)"
with session_scope() as session:
job = Job(
status="completed",
title=request.title or f"Conteo ({total} archivos)",
updated_at=utcnow(),
)
session.add(job)
if request.job_id:
job = session.get(Job, request.job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job no encontrado")
if request.title:
job.title = request.title
job.status = "running"
job.updated_at = utcnow()
else:
job = Job(
status="completed",
title=default_title,
updated_at=utcnow(),
)
session.add(job)
session.flush()
task = Task(
job_id=job.id,
type="counting",
status="completed",
title=job.title,
title=default_title,
request={},
result={
"total": total,
@@ -1266,11 +1329,19 @@ def create_extraction_from_count(
)
).all()
use_exact_counts = request.valid_counts is not None and len(request.valid_counts) > 0
valid_files = [
f for f in all_files
if f.status == "ok"
and f.is_valid is not False
and (f.service_count is None or f.service_count >= request.min_service_count)
and (
# Si el usuario marcó manualmente como válido, siempre se incluye
f.is_valid is True
# Si hay conteos exactos definidos, el service_count debe estar en la lista
or (use_exact_counts and f.service_count in request.valid_counts)
# Sin filtro exacto, usar umbral mínimo (compatibilidad hacia atrás)
or (not use_exact_counts and (f.service_count is None or f.service_count >= request.min_service_count))
)
]
if not valid_files:
@@ -1292,12 +1363,19 @@ def create_extraction_from_count(
]
total = len(files_data)
job = Job(
status="running",
title=f"Extraccion desde conteo ({total} archivos)",
updated_at=utcnow(),
)
session.add(job)
if request.job_id:
job = session.get(Job, request.job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job no encontrado")
job.status = "running"
job.updated_at = utcnow()
else:
job = Job(
status="running",
title=f"Extraccion desde conteo ({total} archivos)",
updated_at=utcnow(),
)
session.add(job)
session.flush()
batch = ExtractionBatch(
@@ -27,7 +27,13 @@ DATABASE_URL = os.getenv(
"postgresql+psycopg://ipsextractor:ipsextractor@database:5432/ipsextractor",
)
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_size=10,
pool_timeout=30,
connect_args={"connect_timeout": 10},
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
@@ -0,0 +1,8 @@
models/
data/
web/
scripts/
.git/
*.md
Dockerfile.autoscaler
docker-compose.yml
@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1
# ── Etapa 1: Descargar ONNX Runtime ───────────────────────────────────────────
FROM debian:bookworm-slim AS onnx-download
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
@@ -49,6 +50,7 @@ func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/health", s.health)
@@ -106,6 +108,8 @@ func (s *Server) countSync(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("POST /v1/count: %d archivo(s), include_rejected=%v", len(files), includeRejected)
type fileInput struct {
filename string
data []byte
@@ -169,6 +173,8 @@ func (s *Server) countSync(w http.ResponseWriter, r *http.Request) {
}
wg.Wait()
log.Printf("POST /v1/count completado: %d procesados, %d fallidos", len(results)-failed, failed)
writeJSON(w, http.StatusOK, domain.CountResponse{
Results: results,
Processed: len(results) - failed,