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:
+24
-3
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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"),
|
||||
|
||||
@@ -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
@@ -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 ≥ {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)}`;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user