From c37c40663dd3366e0fa3c241426288e298564dc3 Mon Sep 17 00:00:00 2001 From: erik Date: Mon, 6 Jul 2026 04:30:27 -0500 Subject: [PATCH] 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. --- .env.example | 27 +- README.md | 52 +- docker-compose.yml | 50 +- services/api_gateway/Dockerfile | 4 + services/api_gateway/README.md | 7 +- services/api_gateway/api/main.py | 104 +- services/desktop_app/README.md | 24 +- services/desktop_app/electron/main.cjs | 4 +- services/desktop_app/package.json | 26 +- services/desktop_app/src/globals.d.ts | 3 + services/desktop_app/src/renderer/App.tsx | 1011 ++--------- .../components/BackgroundProgressBanner.tsx | 45 + .../renderer/components/CountResultsTable.tsx | 519 ++++-- .../renderer/components/CountingWizard.tsx | 463 +++-- .../renderer/components/ExtractionWizard.tsx | 29 +- .../desktop_app/src/renderer/lib/apiClient.ts | 96 +- .../src/renderer/lib/apiDefaults.ts | 25 + .../src/renderer/lib/payloads.test.ts | 1 + .../modules/counting/CountingModule.ts | 18 + .../modules/counting/CountingResults.tsx | 148 ++ .../modules/counting/CountingWizardModule.tsx | 30 + .../modules/extraction/ExtractionModule.ts | 18 + .../modules/extraction/ExtractionResults.tsx | 163 ++ .../extraction/JobExtractionWizard.tsx | 487 ++++++ .../extraction/JobExtractionWizardModule.tsx | 11 + .../src/renderer/modules/registry.ts | 50 + services/desktop_app/src/renderer/styles.css | 1504 +++++++++++++++++ .../src/renderer/types/jobSection.ts | 1 + .../src/renderer/views/HomeView.tsx | 255 +++ .../src/renderer/views/JobResultsView.tsx | 494 ++++++ .../src/renderer/views/JobView.tsx | 179 ++ services/desktop_app/src/shared/appVersion.ts | 16 + services/desktop_app/src/shared/types.ts | 30 +- services/desktop_app/vite.config.ts | 23 +- services/extraction/api/main.py | 106 +- .../extraction/infrastructure/database.py | 8 +- .../.dockerignore | 8 + .../Dockerfile | 2 - .../internal/api/server.go | 6 + 39 files changed, 4815 insertions(+), 1232 deletions(-) create mode 100644 services/desktop_app/src/globals.d.ts create mode 100644 services/desktop_app/src/renderer/components/BackgroundProgressBanner.tsx create mode 100644 services/desktop_app/src/renderer/lib/apiDefaults.ts create mode 100644 services/desktop_app/src/renderer/modules/counting/CountingModule.ts create mode 100644 services/desktop_app/src/renderer/modules/counting/CountingResults.tsx create mode 100644 services/desktop_app/src/renderer/modules/counting/CountingWizardModule.tsx create mode 100644 services/desktop_app/src/renderer/modules/extraction/ExtractionModule.ts create mode 100644 services/desktop_app/src/renderer/modules/extraction/ExtractionResults.tsx create mode 100644 services/desktop_app/src/renderer/modules/extraction/JobExtractionWizard.tsx create mode 100644 services/desktop_app/src/renderer/modules/extraction/JobExtractionWizardModule.tsx create mode 100644 services/desktop_app/src/renderer/modules/registry.ts create mode 100644 services/desktop_app/src/renderer/types/jobSection.ts create mode 100644 services/desktop_app/src/renderer/views/HomeView.tsx create mode 100644 services/desktop_app/src/renderer/views/JobResultsView.tsx create mode 100644 services/desktop_app/src/renderer/views/JobView.tsx create mode 100644 services/desktop_app/src/shared/appVersion.ts create mode 100644 services/services-count-facturas-medicas-serving/.dockerignore diff --git a/.env.example b/.env.example index 7d76bc8..c66f5ea 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index e91aee8..b5657cf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index dfaa886..829374e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/services/api_gateway/Dockerfile b/services/api_gateway/Dockerfile index c9bd7d7..58b8a4e 100644 --- a/services/api_gateway/Dockerfile +++ b/services/api_gateway/Dockerfile @@ -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 diff --git a/services/api_gateway/README.md b/services/api_gateway/README.md index 7fcb649..ab71dbc 100644 --- a/services/api_gateway/README.md +++ b/services/api_gateway/README.md @@ -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). diff --git a/services/api_gateway/api/main.py b/services/api_gateway/api/main.py index 949fa59..3048722 100644 --- a/services/api_gateway/api/main.py +++ b/services/api_gateway/api/main.py @@ -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: diff --git a/services/desktop_app/README.md b/services/desktop_app/README.md index 8472419..bb4b6c1 100644 --- a/services/desktop_app/README.md +++ b/services/desktop_app/README.md @@ -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`. diff --git a/services/desktop_app/electron/main.cjs b/services/desktop_app/electron/main.cjs index ffb864c..0d3ea66 100644 --- a/services/desktop_app/electron/main.cjs +++ b/services/desktop_app/electron/main.cjs @@ -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"), diff --git a/services/desktop_app/package.json b/services/desktop_app/package.json index dac8c75..148a935 100644 --- a/services/desktop_app/package.json +++ b/services/desktop_app/package.json @@ -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}" } } } diff --git a/services/desktop_app/src/globals.d.ts b/services/desktop_app/src/globals.d.ts new file mode 100644 index 0000000..d45cd4b --- /dev/null +++ b/services/desktop_app/src/globals.d.ts @@ -0,0 +1,3 @@ +declare const __APP_VERSION__: string; +declare const __APP_RELEASE_DATE__: string; +declare const __DEFAULT_API_BASE_URL__: string; diff --git a/services/desktop_app/src/renderer/App.tsx b/services/desktop_app/src/renderer/App.tsx index 4151482..14fd387 100644 --- a/services/desktop_app/src/renderer/App.tsx +++ b/services/desktop_app/src/renderer/App.tsx @@ -1,414 +1,94 @@ -import { Activity, AlertCircle, CheckCircle2, Clock, Database, Download, FileSearch, Hash, Loader2, RefreshCw, Server, Settings, XCircle } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import type { DocumentType, ExtractionFormState, ExtractionFromCountPayload, FieldSelection, ManifestEntry, RowsResponse, SchemaResponse, Task } from "../shared/types"; -import { ApiClient } from "./lib/apiClient"; -import { rowsToCsv } from "./lib/csv"; import { - buildDesktopBatchPayload, - buildEstimateUploadForm, - buildIngestFormBulk, - buildSchemaUploadForm, - flattenSchema, - mergeSchemaResponses, - validDocTypes, -} from "./lib/payloads"; -import { defaultAlias } from "./lib/schemaDisplay"; -import { splitRadicados } from "./lib/pathMapping"; -import { ExtractionWizard } from "./components/ExtractionWizard"; -import type { ExtractionStep } from "./components/ExtractionStepper"; -import { CountingWizard } from "./components/CountingWizard"; + BarChart2, + Briefcase, + CheckCircle2, + Cpu, + Database, + Hash, + Loader2, + Server, + Settings, + XCircle, +} from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import type { ExtractionFormState, Job } from "../shared/types"; +import { + APP_RELEASE_DATE, + APP_VERSION, + formatReleaseDate, + formatVersionLabel, +} from "../shared/appVersion"; +import { ApiClient } from "./lib/apiClient"; +import { DEFAULT_API_BASE_URL, loadApiBaseUrl, migrateApiBaseUrlInStorage } from "./lib/apiDefaults"; +import { HomeView } from "./views/HomeView"; +import { JobView } from "./views/JobView"; +import type { JobSection } from "./types/jobSection"; -const defaultForm: ExtractionFormState = { - apiBaseUrl: localStorage.getItem("apiBaseUrl") || "http://localhost:8000", - localRoot: localStorage.getItem("localRoot") || "", - containerRoot: localStorage.getItem("containerRoot") || "/data", - selectedLocalPath: "", - selectedContainerPath: "", - mode: "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", -}; +// Registrar módulos al cargar la app +import "./modules/counting/CountingModule"; +import "./modules/extraction/ExtractionModule"; type Status = "idle" | "ok" | "error" | "busy"; -type View = "extraction" | "counting" | "tasks" | "settings"; +type View = "home" | "job" | "settings"; +export type { JobSection } from "./types/jobSection"; function App() { - const [view, setView] = useState("extraction"); - const [form, setForm] = useState(defaultForm); + const [view, setView] = useState("home"); + const [activeJob, setActiveJob] = useState(null); + const [jobSection, setJobSection] = useState("counting"); + const [jobSectionContext, setJobSectionContext] = useState>({}); + const [apiBaseUrl, setApiBaseUrl] = useState(loadApiBaseUrl); const [connectionStatus, setConnectionStatus] = useState("idle"); const [connectionMessage, setConnectionMessage] = useState("Sin verificar"); const [configJson, setConfigJson] = useState(null); const [modelsJson, setModelsJson] = useState(null); - const [schemaResponse, setSchemaResponse] = useState(null); - const [schemaSearch, setSchemaSearch] = useState(""); - const [selection, setSelection] = useState({ fields: [], aliasMap: {} }); - const [estimateJson, setEstimateJson] = useState(null); - const [tasks, setTasks] = useState([]); - const [activeTask, setActiveTask] = useState(null); - const [rows, setRows] = useState(null); - const [manifest, setManifest] = useState([]); - const [ingestProgress, setIngestProgress] = useState<{ processed: number; total: number } | null>(null); - const [statusText, setStatusText] = useState("Listo"); const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [extractionStep, setExtractionStep] = useState(1); - const [maxReachedStep, setMaxReachedStep] = useState(1); - const [pendingCountTaskId, setPendingCountTaskId] = useState(null); - const [pendingValidCount, setPendingValidCount] = useState(0); - const client = useMemo(() => new ApiClient(form.apiBaseUrl), [form.apiBaseUrl]); - const flattened = useMemo(() => (schemaResponse ? flattenSchema(schemaResponse) : { schema: {}, sample: null }), [schemaResponse]); - const fieldCount = Object.keys(flattened.schema).length; - - const canGoNext = - (extractionStep === 1 && manifest.length > 0) || - (extractionStep === 2 && fieldCount > 0) || - (extractionStep === 3 && selection.fields.length > 0); + const client = useMemo(() => new ApiClient(apiBaseUrl), [apiBaseUrl]); useEffect(() => { - if (!window.desktopApi?.getRuntimeConfig) return; - window.desktopApi.getRuntimeConfig().then((config) => { - updateForm({ - localRoot: config.documentRootLocal || localStorage.getItem("localRoot") || "", - containerRoot: config.documentRootContainer || "/data", - }); - }).catch(() => undefined); - }, []); + migrateApiBaseUrlInStorage(); + const normalized = loadApiBaseUrl(); + if (normalized !== apiBaseUrl) { + setApiBaseUrl(normalized); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { - const hasRunning = tasks.some((task) => task.status === "pending" || task.status === "running"); - if (!activeTask && !hasRunning) return; - const timer = window.setInterval(() => { - refreshTasks(activeTask?.id).catch(() => undefined); - }, 3000); - return () => window.clearInterval(timer); - }, [activeTask?.id, form.apiBaseUrl, tasks]); - - function updateForm(patch: Partial) { - setForm((current) => { - const next = { ...current, ...patch }; - localStorage.setItem("apiBaseUrl", next.apiBaseUrl); - 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; - }); - } + useEffect(() => { localStorage.setItem("apiBaseUrl", apiBaseUrl); }, [apiBaseUrl]); async function verifyConnection() { setConnectionStatus("busy"); setConnectionMessage("Verificando gateway..."); - setError(null); + setBusy(true); try { - const [health, config, models] = await Promise.all([client.health(), client.config(), client.models()]); + const [health, config, models] = await Promise.all([ + client.health(), client.config(), client.models(), + ]); setConfigJson(config); setModelsJson(models); setConnectionStatus("ok"); setConnectionMessage(`Conectado: ${JSON.stringify(health)}`); - await refreshTasks(); } catch (err) { setConnectionStatus("error"); setConnectionMessage(err instanceof Error ? err.message : "No se pudo conectar"); - } - } - - async function indexFolderAt(path: string, mode: ExtractionFormState["mode"], docTypes: DocumentType[]) { - if (!window.desktopApi) throw new Error("La seleccion 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 seleccionada."); - } - 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 seleccion 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 para subir desde tu equipo`); - } catch (err) { - setManifest([]); - setError(err instanceof Error ? err.message : "No se pudo indexar la carpeta"); - setStatusText("Error al indexar 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 en modo ${mode === "general" ? "carpeta general" : "radicados"}`); - } 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("La subida de documentos requiere Electron."); - if (manifest.length === 0) throw new Error("Selecciona una carpeta con documentos antes de continuar."); - - 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 encontro un documento de muestra para el tipo ${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((path) => [path, defaultAlias(path)])), - }); - setEstimateJson(null); - setActiveTask(null); - setRows(null); - setStatusText(`Campos descubiertos: ${Object.keys(flat.schema).length}`); - setMaxReachedStep((current) => (current < 3 ? 3 : current)); - setExtractionStep(3); - }); - } - - function goToStep(step: ExtractionStep) { - setExtractionStep(step); - setMaxReachedStep((current) => (step > current ? step : current)); - } - - function handleBack() { - if (extractionStep > 1) goToStep((extractionStep - 1) as ExtractionStep); - } - - function handleNext() { - if (!canGoNext) return; - if (extractionStep < 4) goToStep((extractionStep + 1) as ExtractionStep); - } - - async function estimate() { - await runTask("Estimando...", async () => { - requireFields(); - if (!window.desktopApi) throw new Error("La estimacion requiere Electron."); - if (manifest.length === 0) throw new Error("Selecciona una carpeta con documentos antes de estimar."); - - 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("Estimacion completada"); - }); - } - - async function runExtraction() { - await runTask("Creando tarea de extraccion...", async () => { - requireFields(); - if (!window.desktopApi) throw new Error("La subida de documentos requiere Electron."); - if (manifest.length === 0) throw new Error("Selecciona una carpeta con documentos antes de continuar."); - - const payload = buildDesktopBatchPayload(form, selection, manifest, form.model); - const task = await client.createExtractionTask(payload); - setActiveTask(task); - setTasks((current) => [task, ...current.filter((item) => item.id !== task.id)]); - setRows(null); - setView("tasks"); - - let workManifest = [...manifest]; - if (form.filterEnabled) { - const allowed = new Set(splitRadicados(form.radicadoFilterText)); - workManifest = workManifest.filter((entry) => allowed.has(entry.radicado)); - } - if (workManifest.length === 0) throw new Error("El filtro de radicados no dejo documentos para procesar."); - - const total = workManifest.length; - setIngestProgress({ processed: 0, total }); - - // El backend procesa en paralelo con Redis/ARQ; aqui solo subimos y - // encolamos por lotes (mas rapido que 1 peticion HTTP por archivo). - // La extraccion real ocurre en segundo plano y se refleja via polling - // de la tarea (ver useEffect de refreshTasks). - 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 nextBatchIndex = 0; - - async function uploadBatch(batch: ManifestEntry[]) { - const files = await Promise.all( - batch.map((entry) => window.desktopApi!.readFile(entry.localPath)), - ); - const blobs = files.map((fileData) => new Blob([fileData.data])); - await client.ingestTaskDocumentsBulk(task.id, buildIngestFormBulk(batch, blobs)); - queued += batch.length; - setIngestProgress({ processed: queued, total }); - setStatusText(`Encolados ${queued} de ${total} documento(s) para procesamiento...`); - } - - async function worker() { - while (nextBatchIndex < batches.length) { - const batch = batches[nextBatchIndex]; - nextBatchIndex += 1; - await uploadBatch(batch); - } - } - - await Promise.all(Array.from({ length: Math.min(BATCH_CONCURRENCY, batches.length) }, () => worker())); - - setIngestProgress(null); - setStatusText(`${total} documento(s) encolados. Procesando en segundo plano...`); - await refreshTasks(task.id); - }); - } - - async function refreshTasks(selectTaskId?: string) { - const list = await client.tasks("extraction"); - setTasks(list); - const selected = selectTaskId ? list.find((task) => task.id === selectTaskId) : activeTask ? list.find((task) => task.id === activeTask.id) : null; - if (selected) { - setActiveTask(selected); - if (selected.status === "completed") { - const taskRows = await client.taskRows(selected.id, 500, 0); - setRows(taskRows); - setStatusText(`Tarea completada con ${taskRows.rows.length} filas`); - } else if (selected.status === "failed") { - setStatusText("Tarea fallida"); - setError(selected.error || "La tarea fallo sin detalle."); - } - } - } - - async function selectTask(task: Task) { - setActiveTask(task); - setError(task.status === "failed" ? task.error : null); - if (task.status === "completed") { - await runTask("Cargando filas de la tarea...", async () => { - const taskRows = await client.taskRows(task.id, 500, 0); - setRows(taskRows); - }); - } else { - setRows(null); - } - } - - async function exportCsv() { - await runTask("Exportando CSV...", async () => { - if (!rows || rows.rows.length === 0) throw new Error("No hay filas para exportar."); - if (!window.desktopApi) throw new Error("Exportar CSV requiere Electron."); - const content = rowsToCsv(rows.rows, selection.aliasMap); - const target = await window.desktopApi.saveCsvDialog(`extraccion-${rows.batch_id}.csv`); - if (!target) return; - await window.desktopApi.writeTextFile(target, content); - setStatusText(`CSV guardado: ${target}`); - }); - } - - async function runTask(label: string, task: () => Promise) { - setBusy(true); - setError(null); - setStatusText(label); - try { - await task(); - } catch (err) { - const message = err instanceof Error ? err.message : "Error desconocido"; - setError(message); - setStatusText("Error"); } finally { setBusy(false); } } - function requireFields() { - if (!form.selectedLocalPath || manifest.length === 0) { - throw new Error("Selecciona una carpeta con documentos antes de continuar."); - } - if (selection.fields.length === 0) throw new Error("Selecciona al menos un campo."); + function openJob(job: Job) { + setActiveJob(job); + setJobSection("counting"); + setJobSectionContext({}); + setView("job"); } - function setDocType(index: number, patch: Partial) { - updateForm({ - docTypes: form.docTypes.map((item, idx) => (idx === index ? { ...item, ...patch } : item)), - }); + function handleActivateSection(section: JobSection, context: Record = {}) { + setJobSection(section); + setJobSectionContext(context); } - function toggleField(path: string, checked: boolean) { - setSelection((current) => { - const fields = checked ? Array.from(new Set([...current.fields, path])) : current.fields.filter((field) => field !== path); - const aliasMap = { ...current.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((path) => [path, selection.aliasMap[path] || defaultAlias(path)]), - ), - }); - } - - const currentTitle = - view === "extraction" ? "Nueva extraccion" - : view === "counting" ? "Contar servicios" - : view === "tasks" ? "Tareas y resultados" - : "Configuracion"; - const currentDescription = - view === "extraction" - ? "Asistente paso a paso para extraer datos de tus documentos." - : view === "counting" - ? "Cuenta servicios en facturas medicas y selecciona archivos validos para extraer." - : view === "tasks" - ? "Consulta el historial de ejecuciones y las filas guardadas en la base de datos." - : "Administra la conexion al API Gateway local."; + const isJobOpen = view === "job" && activeJob; return (
@@ -421,137 +101,123 @@ function App() { - + {isJobOpen ? ( + /* ── Submenú del job activo ── */ + <> + +
+ {activeJob.title ?? "Sin título"} +
+
+ +
+ + + ) : ( + /* ── Menú principal ── */ + + )}
- {connectionStatus === "ok" ? "Conectado" : connectionStatus === "busy" ? "Verificando..." : connectionStatus === "error" ? "Sin conexion" : "Sin verificar"} + {connectionStatus === "ok" + ? "Conectado" + : connectionStatus === "busy" + ? "Verificando..." + : connectionStatus === "error" + ? "Sin conexión" + : "Sin verificar"}
+
+ v{APP_VERSION} + {formatReleaseDate(APP_RELEASE_DATE)} +
-
- {(view === "extraction" || view === "counting") ? ( -
-

{currentTitle}

- -
- ) : ( -
-
-

{currentTitle}

-

{currentDescription}

-
-
- -
-
+
+
+ {formatVersionLabel()} +
+ + {view === "home" && ( + )} - {view === "extraction" && ( - <> - {pendingCountTaskId && ( - setPendingCountTaskId(null)} - client={client} - form={form} - selection={selection} - onTaskCreated={(task) => { - setPendingCountTaskId(null); - setTasks((prev) => [task, ...prev]); - setActiveTask(task); - setView("tasks"); - }} - /> - )} - - - )} - - {view === "counting" && ( - { - setPendingCountTaskId(countTaskId); - setPendingValidCount(validCount); - setView("extraction"); - }} - /> - )} - - {view === "tasks" && ( - setActiveJob(updated)} /> )} {view === "settings" && ( }) { - const processed = Number(result.processed || 0); - const total = Number(result.total || 0); - if (!total || total <= 0) return null; - const percent = Math.min(100, Math.round((processed / total) * 100)); - return ( -
-
- Procesando documentos - {processed} / {total} -
-
-
-
-
- ); -} - -function TaskStatusIcon({ status }: { status: string }) { - if (status === "completed") return ; - if (status === "failed") return ; - if (status === "running") return ; - return ; -} - -function formatRelativeTime(dateStr: string | null): string { - if (!dateStr) return "—"; - const diff = Date.now() - new Date(dateStr).getTime(); - const s = Math.floor(diff / 1000); - if (s < 60) return "hace un momento"; - const m = Math.floor(s / 60); - if (m < 60) return `hace ${m} min`; - const h = Math.floor(m / 60); - if (h < 24) return `hace ${h} h`; - return `hace ${Math.floor(h / 24)} d`; -} - -function TasksView({ - tasks, - activeTask, - rows, - busy, - refreshTasks, - selectTask, - exportCsv, -}: { - tasks: Task[]; - activeTask: Task | null; - rows: RowsResponse | null; - busy: boolean; - refreshTasks: (selectTaskId?: string) => Promise; - selectTask: (task: Task) => Promise; - exportCsv: () => Promise; -}) { - const [detailOpen, setDetailOpen] = useState(false); - - return ( -
- {/* ── Panel izquierdo: lista ── */} - - - {/* ── Panel derecho: detalle + resultados ── */} -
- {!activeTask ? ( -
- -

Selecciona una tarea para ver sus resultados

-
- ) : ( - <> - {/* Cabecera de tarea activa */} -
-
- -

{activeTask.title || activeTask.id}

- {activeTask.status} -
-
- {activeTask.created_at && Creada: {new Date(activeTask.created_at).toLocaleString("es-CO")}} - {activeTask.finished_at && Finalizada: {new Date(activeTask.finished_at).toLocaleString("es-CO")}} - {activeTask.error && ( - - {activeTask.error} - - )} -
- {activeTask.status === "running" && } -
- - -
- {detailOpen && ( -
{JSON.stringify(activeTask, null, 2)}
- )} -
- - {/* Tabla de resultados */} -
-
- - Resultados - {rows && {rows.rows.length} filas} - -
- -
- - )} -
-
- ); -} +type SettingsTab = "config" | "models"; function SettingsView({ - form, - updateForm, + apiBaseUrl, + onApiBaseUrlChange, connectionStatus, connectionMessage, verifyConnection, @@ -746,8 +243,8 @@ function SettingsView({ configJson, modelsJson, }: { - form: ExtractionFormState; - updateForm: (patch: Partial) => void; + apiBaseUrl: string; + onApiBaseUrlChange: (url: string) => void; connectionStatus: Status; connectionMessage: string; verifyConnection: () => Promise; @@ -755,7 +252,7 @@ function SettingsView({ configJson: unknown; modelsJson: unknown; }) { - const [backendTab, setBackendTab] = useState<"config" | "models">("config"); + const [backendTab, setBackendTab] = useState("config"); const connectionLabel = connectionStatus === "ok" @@ -763,31 +260,23 @@ function SettingsView({ : connectionStatus === "busy" ? "Verificando..." : connectionStatus === "error" - ? "Sin conexion" + ? "Sin conexión" : "Sin verificar"; const backendData = backendTab === "config" ? configJson : modelsJson; - const hasBackendData = backendData !== null && backendData !== undefined; return (
@@ -819,38 +306,31 @@ function SettingsView({
-

Configuracion del backend

+

Configuración del backend

-
+
+ >Configuración + >Modelos
-
- {hasBackendData ? ( + {backendData != null ? (
{JSON.stringify(backendData, null, 2)}
) : (

Sin datos del backend

- Verifica la conexion para cargar la {backendTab === "config" ? "configuracion" : "lista de modelos"}. + Verifica la conexión para cargar la {backendTab === "config" ? "configuración" : "lista de modelos"}.
)}
@@ -859,135 +339,4 @@ function SettingsView({ ); } -function ResultsTable({ rows, status }: { rows: Array>; status?: string }) { - const columns = useMemo(() => Array.from(new Set(rows.flatMap((row) => Object.keys(row)))).slice(0, 12), [rows]); - if (rows.length === 0) { - const isRunning = status === "running" || status === "pending"; - return ( -
- {isRunning - ? <>

Extraccion en curso...

- : <>

No hay filas extraidas aun.

- } -
- ); - } - return ( -
- - - {columns.map((column) => )} - - - {rows.slice(0, 100).map((row, index) => ( - {columns.map((column) => )} - ))} - -
{column}
{formatCell(row[column])}
-
- ); -} - -function formatCell(value: unknown): string { - if (value === null || value === undefined) return ""; - if (typeof value === "object") return JSON.stringify(value); - return String(value); -} - -function FromCountBanner({ - countTaskId, - validCount, - onDismiss, - client, - form, - selection, - onTaskCreated, -}: { - countTaskId: string; - validCount: number; - onDismiss: () => void; - client: ApiClient; - form: ExtractionFormState; - selection: FieldSelection; - onTaskCreated: (task: Task) => void; -}) { - const [minServiceCount, setMinServiceCount] = useState(1); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - async function run() { - if (!selection.fields.length) { - setError("Descubre y selecciona campos en el paso 2 y 3 antes de extraer."); - return; - } - setBusy(true); - setError(null); - try { - const payload: ExtractionFromCountPayload = { - counting_task_id: countTaskId, - min_service_count: minServiceCount, - fields: selection.fields, - alias_map: selection.aliasMap, - doc_types: form.docTypes, - structure_mode: "general", - model: form.model || null, - }; - const task = await client.createExtractionFromCount(payload); - onTaskCreated(task); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setBusy(false); - } - } - - return ( -
-
-
-

- Extraccion desde conteo — {validCount} archivo(s) valido(s) -

-

- Configura los campos (pasos 2 y 3) y luego haz clic en Extraer. -

-
- -
- -
-
- - setMinServiceCount(Number(e.target.value))} - className="w-16 px-2 py-1 text-xs rounded border border-indigo-300 dark:border-indigo-600 bg-white dark:bg-slate-800" - /> -
- -
- - {error && ( -

- - {error} -

- )} -
- ); -} - export default App; diff --git a/services/desktop_app/src/renderer/components/BackgroundProgressBanner.tsx b/services/desktop_app/src/renderer/components/BackgroundProgressBanner.tsx new file mode 100644 index 0000000..75ff816 --- /dev/null +++ b/services/desktop_app/src/renderer/components/BackgroundProgressBanner.tsx @@ -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 ( +
+
+ + + {moduleLabel} en curso — {progress.label} + + + {progress.processed} / {progress.total} + {progress.failed > 0 && ( + · {progress.failed} error(es) + )} + +
+
+
+
+ {!compact && progress.currentFile && ( +

+ {progress.currentFile} +

+ )} +
+ ); +} diff --git a/services/desktop_app/src/renderer/components/CountResultsTable.tsx b/services/desktop_app/src/renderer/components/CountResultsTable.tsx index cf28465..b385cfc 100644 --- a/services/desktop_app/src/renderer/components/CountResultsTable.tsx +++ b/services/desktop_app/src/renderer/components/CountResultsTable.tsx @@ -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; + onToggleValid: (file: CountJobFile, isValid: boolean | null) => Promise; + 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("all"); + const [search, setSearch] = useState(""); + const [expanded, setExpanded] = useState>(new Set()); + const [toggling, setToggling] = useState>(new Set()); + const tagInputRef = useRef(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 ; - if (f.is_valid === true) return ; - if (f.is_valid === false) return ; - if ((f.service_count ?? 0) >= minServiceCount) return ; - return ; + 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 ( -
- {/* Summary */} -
-
-

{summary.total}

-

Total archivos

+
+ {/* ── Summary ── */} +
+
+ {summary.total} + Total
-
-

{summary.valid}

-

Válidos para extracción

+
+ {summary.valid} + Válidos
-
-

{summary.errors}

-

Errores de procesamiento

+
+ {summary.rejected} + Fuera de filtro +
+ {summary.errors > 0 && ( +
+ {summary.errors} + Errores +
+ )} +
+ + {/* ── Filtro por conteo exacto ── */} +
+
+ + {validCounts.length > 0 && ( + + )} +
+ +
+ {validCounts.map((n) => ( + + = {n} + + + ))} + { + 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} + /> +
+ +

{filterHint}

+ + {autoAdjustedFilenames && autoAdjustedFilenames.size > 0 && ( +
+ + + {autoAdjustedFilenames.size} archivo(s) con conteo 0 se ajustaron automáticamente a{" "} + 1 por regla de negocio. + +
+ )} +
+ + {/* ── Toolbar ── */} +
+
+ {FILTERS.map(({ key, label, count }) => ( + + ))} +
+ +
+
+ + setSearch(e.target.value)} + /> +
+ {onExportCsv && ( + + )}
- {/* Threshold slider */} -
-
- - - ≥ {minServiceCount} - -
- 0 ? maxCount : 20} - value={minServiceCount} - onChange={(e) => onMinServiceCountChange(Number(e.target.value))} - className="w-full accent-indigo-600" - disabled={loading} - /> -

- 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).`} -

-
- - {/* Filter tabs */} -
- {(["all", "valid", "invalid", "unreviewed"] as const).map((f) => ( - - ))} -
- - {/* Table */} -
-
- - + {/* ── Table ── */} +
+
+ + + + + + + + + + {filtered.length === 0 && ( - - - - + - - - {filtered.length === 0 && ( - - - - )} - {filtered.map((f) => ( - - - - - + - - ))} - -
ArchivoServiciosEstadoValidez
ArchivoServiciosEstadoValidez + {search + ? "No hay archivos que coincidan con la búsqueda." + : "No hay archivos en esta categoría."} +
- No hay archivos que mostrar -
-
- {f.filename} -
- {f.radicado && f.radicado !== f.filename && ( -
{f.radicado}
- )} - {f.status === "error" && ( -
{f.error}
- )} -
- {f.status === "error" ? ( - — - ) : ( - - {f.service_count ?? "?"} - - )} - -
{validityIcon(f)}
-
- {f.status !== "error" && ( -
- - + )} + {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 ( + <> +
+
+ {hasServices ? ( + + ) : ( + + )} +
+
+ {f.filename} + {wasAutoAdjusted && ( + + auto→1 + + )} +
+ {f.radicado && f.radicado !== f.filename && ( + {f.radicado} + )} + {f.status === "error" && f.error && ( + {f.error} + )} +
- )} -
-
+ + + + {f.status === "error" ? ( + — + ) : ( + {f.service_count ?? "?"} + )} + + + + {st === "error" && } + {st === "valid" && } + {st === "invalid" && } + + + + {f.status !== "error" && ( +
+ + +
+ )} + + + + {isExpanded && hasServices && ( + + +
+ {f.services.map((svc, i) => ( +
+ {svc.text} + {(svc.probability * 100).toFixed(0)}% +
+ ))} +
+ + + )} + + ); + })} + +
); diff --git a/services/desktop_app/src/renderer/components/CountingWizard.tsx b/services/desktop_app/src/renderer/components/CountingWizard.tsx index e8d8d7b..894cdca 100644 --- a/services/desktop_app/src/renderer/components/CountingWizard.tsx +++ b/services/desktop_app/src/renderer/components/CountingWizard.tsx @@ -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(1); const [maxReached, setMaxReached] = useState(1); const [form, setForm] = useState({ localRoot, containerRoot, includeRejected: false, - minServiceCount: 1, + validCounts: [1], }); const [docTypes] = useState([ { name: "Factura", keyword: "Factura_O_Documento_Equivalente" }, ]); const [manifest, setManifest] = useState([]); - const [progress, setProgress] = useState({ processed: 0, total: 0, failed: 0 }); + const [indexing, setIndexing] = useState(false); + const [progress, setProgress] = useState({ + processed: 0, + total: 0, + failed: 0, + currentBatch: "", + startedAt: 0, + }); const [savedTask, setSavedTask] = useState(null); const [savedFiles, setSavedFiles] = useState([]); + const [autoAdjustedFilenames, setAutoAdjustedFilenames] = useState>(new Set()); const [error, setError] = useState(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(); + + 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(); + 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 (
+ {/* ── PASO 1: Carpeta ── */} {step === 1 && (

Origen de las facturas

- 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.

@@ -267,12 +449,13 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac Ninguna carpeta seleccionada )}
-
- {manifest.length > 0 && ( + {manifest.length > 0 && !indexing && (
@@ -280,23 +463,39 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
)} - {form.localRoot && manifest.length === 0 && !error && ( -

Buscando PDF en la carpeta…

+ {form.localRoot && manifest.length === 0 && !indexing && !error && ( +

No se encontraron PDF en esta carpeta.

)}
-
- - setForm((f) => ({ ...f, containerRoot: e.target.value }))} - placeholder="/data" - /> -

- Prefijo de ruta dentro del contenedor Docker donde estan montados los archivos. -

-
+
+ Opciones avanzadas +
+
+ + setForm((f) => ({ ...f, containerRoot: e.target.value }))} + placeholder="/data" + /> +

+ Prefijo donde están montados los documentos dentro de Docker (para guardar rutas en BD). +

+
+ +
+ +
+
+
)} @@ -304,15 +503,15 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac {step === 2 && (
-
+
{running ? : }

- {running ? "Analizando facturas…" : "Analisis completado"} + {running ? "Analizando facturas…" : "Análisis completado"}

{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."}

@@ -320,15 +519,16 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
- {running ? "Procesando documentos" : "Procesados"} + {running && progress.currentBatch + ? {progress.currentBatch} + : running ? "Procesando…" : "Completado"} - - {progress.processed} / {progress.total} + + {progress.processed} / {progress.total} {progress.failed > 0 && ( - - {progress.failed} error(es) - + · {progress.failed} error(es) )} + {eta && · {eta}}
@@ -364,52 +564,35 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac {savedFiles.length} archivos )} -
- {savedTask && ( + {savedTask ? ( setForm((f) => ({ ...f, minServiceCount: v }))} + validCounts={form.validCounts} + onValidCountsChange={(v) => setForm((f) => ({ ...f, validCounts: v }))} + autoAdjustedFilenames={autoAdjustedFilenames} onToggleValid={handleToggleValid} + onExportCsv={handleExportCsv} loading={running} /> - )} -
- -
- - {canExtract && ( -

- Se procesaran los archivos con ≥ {form.minServiceCount} servicio(s) -

- )} - {!canExtract && savedFiles.length > 0 && ( -

- Ajusta el umbral de servicios minimos para habilitar la extraccion. -

+ ) : ( + !error && ( +
+ +

No se pudieron guardar los resultados.

+
+ ) )}
)}
- {/* ── Status / Errores ── */} + {/* ── Toast de errores ── */} {error && (
@@ -417,7 +600,7 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac
)} - {/* ── Footer de navegacion ── */} + {/* ── Footer de navegación ── */}
@@ -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} > Iniciar conteo @@ -468,7 +651,13 @@ export function CountingWizard({ client, localRoot, containerRoot, onStartExtrac onClick={handleStartExtraction} disabled={!canExtract} > - Extraer validos + + Extraer válidos + {canExtract && ( + + {savedFiles.filter(isFileValid).length} + + )} )}
diff --git a/services/desktop_app/src/renderer/components/ExtractionWizard.tsx b/services/desktop_app/src/renderer/components/ExtractionWizard.tsx index 35ede1f..056dee9 100644 --- a/services/desktop_app/src/renderer/components/ExtractionWizard.tsx +++ b/services/desktop_app/src/renderer/components/ExtractionWizard.tsx @@ -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({
- {manifest.length} - Documentos + + {displayDocCount} + + + {documentSourceLabel ? `Docs (${documentSourceLabel})` : "Documentos"} +
{form.mode === "radicados" ? "Radicados" : "General"} @@ -450,8 +465,12 @@ export function ExtractionWizard({
- Documentos - {manifest.length} + + {documentSourceLabel ? `Documentos (${documentSourceLabel})` : "Documentos"} + + + {displayDocCount} +
@@ -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; diff --git a/services/desktop_app/src/renderer/lib/apiClient.ts b/services/desktop_app/src/renderer/lib/apiClient.ts index 93001dd..8e0a43f 100644 --- a/services/desktop_app/src/renderer/lib/apiClient.ts +++ b/services/desktop_app/src/renderer/lib/apiClient.ts @@ -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 { + 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(path: string): Promise { - const response = await fetch(this.url(path)); + const response = await fetchWithTimeout(this.url(path), {}, this.timeoutFor(path)); return this.handle(response); } async postJson(path: string, body: unknown): Promise { - 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(response); } async postForm(path: string, form: FormData): Promise { - 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(response); } @@ -113,6 +160,22 @@ export class ApiClient { return this.getJson(`/jobs/${encodeURIComponent(jobId)}/tasks`); } + async createJob(payload: CreateJobPayload): Promise { + return this.postJson("/jobs", payload); + } + + async updateJob(jobId: string, payload: UpdateJobPayload): Promise { + const response = await fetchWithTimeout( + this.url(`/jobs/${encodeURIComponent(jobId)}`), + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + return this.handle(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 { - 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(response); } diff --git a/services/desktop_app/src/renderer/lib/apiDefaults.ts b/services/desktop_app/src/renderer/lib/apiDefaults.ts new file mode 100644 index 0000000..c8e46ad --- /dev/null +++ b/services/desktop_app/src/renderer/lib/apiDefaults.ts @@ -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); + } +} diff --git a/services/desktop_app/src/renderer/lib/payloads.test.ts b/services/desktop_app/src/renderer/lib/payloads.test.ts index a687b3e..6a45385 100644 --- a/services/desktop_app/src/renderer/lib/payloads.test.ts +++ b/services/desktop_app/src/renderer/lib/payloads.test.ts @@ -17,6 +17,7 @@ const baseForm: ExtractionFormState = { tiffEnabled: true, tiffStart: "1", tiffEnd: "3", + model: "gpt-5.4-mini", }; const manifest: ManifestEntry[] = [ diff --git a/services/desktop_app/src/renderer/modules/counting/CountingModule.ts b/services/desktop_app/src/renderer/modules/counting/CountingModule.ts new file mode 100644 index 0000000..16da104 --- /dev/null +++ b/services/desktop_app/src/renderer/modules/counting/CountingModule.ts @@ -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 }; diff --git a/services/desktop_app/src/renderer/modules/counting/CountingResults.tsx b/services/desktop_app/src/renderer/modules/counting/CountingResults.tsx new file mode 100644 index 0000000..1e5d848 --- /dev/null +++ b/services/desktop_app/src/renderer/modules/counting/CountingResults.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [validCounts, setValidCounts] = useState([1]); + const [autoAdjustedFilenames] = useState>(new Set()); + const [error, setError] = useState(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 ( +
+ + Cargando resultados… +
+ ); + } + + if (error) { + return ( +
+ {error} + +
+ ); + } + + return ( +
+
+
+

{task.title}

+ {files.length} archivos +
+
+ + + {onActivateModule && validCount > 0 && ( + + )} +
+
+ + +
+ ); +} diff --git a/services/desktop_app/src/renderer/modules/counting/CountingWizardModule.tsx b/services/desktop_app/src/renderer/modules/counting/CountingWizardModule.tsx new file mode 100644 index 0000000..b52dc66 --- /dev/null +++ b/services/desktop_app/src/renderer/modules/counting/CountingWizardModule.tsx @@ -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 ( + { + onActivateModule?.("extraction", { countingTaskId, validCount, validCounts, jobId: job.id }); + }} + /> + ); +} diff --git a/services/desktop_app/src/renderer/modules/extraction/ExtractionModule.ts b/services/desktop_app/src/renderer/modules/extraction/ExtractionModule.ts new file mode 100644 index 0000000..8afb66f --- /dev/null +++ b/services/desktop_app/src/renderer/modules/extraction/ExtractionModule.ts @@ -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 }; diff --git a/services/desktop_app/src/renderer/modules/extraction/ExtractionResults.tsx b/services/desktop_app/src/renderer/modules/extraction/ExtractionResults.tsx new file mode 100644 index 0000000..25e6e26 --- /dev/null +++ b/services/desktop_app/src/renderer/modules/extraction/ExtractionResults.tsx @@ -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; + +export function ExtractionResults({ client, task }: ModuleResultsProps) { + const [batch, setBatch] = useState(null); + const [rows, setRows] = useState([]); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [polling, setPolling] = useState | 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)?.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) => 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 ( +
+ + Cargando resultados de extracción… +
+ ); + } + + if (error) { + return ( +
+ {error} + +
+ ); + } + + const isRunning = batch?.status === "running" || batch?.status === "pending"; + + return ( +
+
+
+

{task.title}

+ + {isRunning && } + {batch?.status ?? "pendiente"} + +
+ {rows.length > 0 && ( + + )} +
+ + {isRunning && ( +
+
+ Extracción en progreso… + +
+
+
+
+
+ )} + + {rows.length === 0 && !isRunning ? ( +
+

No se encontraron filas extraídas.

+
+ ) : ( +
+ + + + {columns.map((c) => ( + + ))} + + + + {rows.map((row, i) => ( + + {columns.map((c) => ( + + ))} + + ))} + +
{c}
+ {row[c] === null || row[c] === undefined + ? — + : String(row[c])} +
+
+ )} +
+ ); +} diff --git a/services/desktop_app/src/renderer/modules/extraction/JobExtractionWizard.tsx b/services/desktop_app/src/renderer/modules/extraction/JobExtractionWizard.tsx new file mode 100644 index 0000000..779948b --- /dev/null +++ b/services/desktop_app/src/renderer/modules/extraction/JobExtractionWizard.tsx @@ -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 }, 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(() => makeDefaultForm(job, !!countingTaskId)); + const [schemaResponse, setSchemaResponse] = useState(null); + const [schemaSearch, setSchemaSearch] = useState(""); + const [selection, setSelection] = useState({ fields: [], aliasMap: {} }); + const [estimateJson, setEstimateJson] = useState(null); + const [manifest, setManifest] = useState([]); + const [ingestProgress, setIngestProgress] = useState<{ processed: number; total: number } | null>(null); + const [statusText, setStatusText] = useState("Listo"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Si viene del conteo, empezamos en paso 2 (los archivos ya están definidos) + const [extractionStep, setExtractionStep] = useState(() => countingTaskId ? 2 : 1); + const [maxReachedStep, setMaxReachedStep] = useState(() => countingTaskId ? 2 : 1); + const [taskCreated, setTaskCreated] = useState(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) { + 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 { + 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[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) { + 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) { + 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 ( +
+

✓ Extracción iniciada

+

+ Los resultados aparecerán automáticamente en esta pestaña. +

+
+ ); + } + + return ( +
+ {/* ── Banner "desde conteo" — solo cuando viene de archivos válidos del conteo ── */} + {countingTaskId && ( +
+ + + Extracción desde conteo — solo se procesarán los{" "} + {validCount ?? "…"} archivos válidos seleccionados. La carpeta se usa + únicamente para descubrir los campos del documento. + + + + + {validCount ?? "…"} archivos + +
+ )} + + 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} + /> +
+ ); +} diff --git a/services/desktop_app/src/renderer/modules/extraction/JobExtractionWizardModule.tsx b/services/desktop_app/src/renderer/modules/extraction/JobExtractionWizardModule.tsx new file mode 100644 index 0000000..6136b98 --- /dev/null +++ b/services/desktop_app/src/renderer/modules/extraction/JobExtractionWizardModule.tsx @@ -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 ; +} diff --git a/services/desktop_app/src/renderer/modules/registry.ts b/services/desktop_app/src/renderer/modules/registry.ts new file mode 100644 index 0000000..aec1251 --- /dev/null +++ b/services/desktop_app/src/renderer/modules/registry.ts @@ -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) => void; + /** Contexto pasado al activar este módulo desde otro */ + context?: Record; +} + +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) => 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; + /** Componente para ver resultados de una tarea completada */ + Results: React.FC; +} + +// 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; +} diff --git a/services/desktop_app/src/renderer/styles.css b/services/desktop_app/src/renderer/styles.css index a260d4f..ab82bae 100644 --- a/services/desktop_app/src/renderer/styles.css +++ b/services/desktop_app/src/renderer/styles.css @@ -1,4 +1,13 @@ :root { + --bg: #f4f6f8; + --surface: #ffffff; + --text: #18212f; + --text-muted: #5d697b; + --border: #d5dde8; + --input-bg: #ffffff; + --primary: #2e7db2; + --primary-ghost: rgba(46, 125, 178, 0.08); + --success: #16a34a; color: #18212f; background: #f4f6f8; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -155,6 +164,37 @@ button:disabled { gap: 8px; } +.sidebar-version { + color: #6b7f96; + display: flex; + flex-direction: column; + font-size: 11px; + font-weight: 500; + gap: 2px; + line-height: 1.3; + margin-top: 10px; + padding: 0 4px; +} + +.app-topbar { + align-items: center; + background: var(--surface); + border-bottom: 1px solid var(--border); + display: flex; + flex-shrink: 0; + height: 36px; + justify-content: flex-end; + padding: 0 20px; +} + +.app-version-badge { + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.01em; + user-select: none; +} + .gateway-dot { border-radius: 999px; flex-shrink: 0; @@ -2449,3 +2489,1467 @@ th { margin-top: 8px; line-height: 1.4; } + +/* ═══════════════════════════════════════════ + Count Results Table + ═══════════════════════════════════════════ */ + +.count-results { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 12px; +} + +/* Summary stat overrides */ +.count-results-summary { + justify-content: flex-start; + gap: 0; + border: 1px solid #e8edf4; + border-radius: 10px; + overflow: hidden; + background: #ffffff; +} + +.count-results-summary .wiz-stat { + flex: 1; + padding: 12px 16px; +} + +.count-results-summary .wiz-stat + .wiz-stat { + border-left: 1px solid #e8edf4; +} + +.count-results-summary .wiz-stat + .wiz-stat::before { + display: none; +} + +.count-stat--valid .wiz-stat-value { color: #1a6b4a; } +.count-stat--invalid .wiz-stat-value { color: #92400e; } +.count-stat--error .wiz-stat-value { color: #991b1b; } + +/* Threshold */ +.count-threshold { + background: #ffffff; + border: 1px solid #e8edf4; + border-radius: 10px; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.count-threshold-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.count-threshold-value { + font-size: 13px; + font-weight: 700; + color: #2e7db2; + font-variant-numeric: tabular-nums; +} + +.count-slider { + width: 100%; + accent-color: #2e7db2; + cursor: pointer; +} + +/* Toolbar */ +.count-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.count-filter-tabs { + display: flex; + gap: 4px; + flex: 1; + flex-wrap: wrap; +} + +.count-filter-tab { + align-items: center; + background: #ffffff; + border: 1px solid #d5dde8; + border-radius: 999px; + color: #5d697b; + display: inline-flex; + font-size: 12px; + font-weight: 500; + gap: 5px; + min-height: 28px; + padding: 0 12px; + transition: all 0.15s ease; + white-space: nowrap; +} + +.count-filter-tab:hover:not(:disabled) { + background: #f3f6fa; + border-color: #b0bac8; + color: #354154; +} + +.count-filter-tab.active { + background: #2e7db2; + border-color: #2e7db2; + color: #ffffff; + font-weight: 600; +} + +.count-filter-count { + background: rgba(0, 0, 0, 0.1); + border-radius: 999px; + font-size: 10px; + font-weight: 700; + min-width: 18px; + padding: 1px 5px; + text-align: center; +} + +.count-filter-tab.active .count-filter-count { + background: rgba(255, 255, 255, 0.25); +} + +.count-toolbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +/* Table */ +.count-table-wrap { + border: 1px solid #e8edf4; + border-radius: 10px; + overflow: hidden; +} + +.count-table { + border-collapse: collapse; + font-size: 13px; + width: 100%; +} + +.count-th { + background: #f8fafc; + border-bottom: 1px solid #e8edf4; + color: #6b7f96; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + padding: 8px 12px; + position: sticky; + text-align: left; + text-transform: uppercase; + top: 0; + z-index: 1; +} + +.count-th-center { text-align: center; } +.count-th-narrow { width: 80px; } + +.count-row { + border-bottom: 1px solid #f0f4f9; + transition: background 0.1s ease; +} + +.count-row:last-child { border-bottom: 0; } + +.count-row--valid { background: #f0faf5; } +.count-row--invalid { background: #fefce8; } +.count-row--error { background: #fff5f5; } + +.count-row:hover { filter: brightness(0.97); } + +.count-td { + padding: 8px 12px; + vertical-align: middle; +} + +.count-td-center { text-align: center; } + +.count-file-cell { + display: flex; + align-items: flex-start; + gap: 6px; +} + +.count-expand-btn { + align-items: center; + background: transparent; + border: 0; + border-radius: 4px; + color: #94a3b8; + display: inline-flex; + flex-shrink: 0; + justify-content: center; + margin-top: 1px; + min-height: 20px; + padding: 0 2px; + transition: color 0.15s ease; +} + +.count-expand-btn:hover { color: #2e7db2; } + +.count-expand-placeholder { + display: inline-block; + width: 20px; + flex-shrink: 0; +} + +.count-file-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.count-filename { + color: #18212f; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.count-radicado { + color: #7b8698; + font-size: 11.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.count-error-msg { + color: #dc2626; + font-size: 11px; + line-height: 1.4; +} + +.count-service-num { + color: #18212f; + font-size: 15px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.count-dash { color: #94a3b8; } + +.count-icon { display: block; margin: 0 auto; } +.count-icon--valid { color: #16a34a; } +.count-icon--warn { color: #d97706; } +.count-icon--error { color: #dc2626; } + +/* Validity toggle buttons */ +.count-validity-btns { + display: flex; + gap: 4px; + justify-content: center; +} + +.count-validity-btn { + align-items: center; + background: #f1f5f9; + border: 1px solid #d5dde8; + border-radius: 6px; + color: #64748b; + display: inline-flex; + font-size: 13px; + font-weight: 700; + justify-content: center; + min-height: 26px; + min-width: 28px; + padding: 0 4px; + transition: all 0.15s ease; +} + +.count-validity-btn:hover:not(:disabled) { + border-color: #94a3b8; +} + +.count-validity-btn--accept { + background: #16a34a; + border-color: #16a34a; + color: #ffffff; +} + +.count-validity-btn--reject { + background: #dc2626; + border-color: #dc2626; + color: #ffffff; +} + +/* Expanded services row */ +.count-row-expanded td { + background: #f8fafc; + border-bottom: 1px solid #e8edf4; + padding: 10px 16px 10px 42px; +} + +.count-services-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 200px; + overflow-y: auto; +} + +.count-service-item { + align-items: baseline; + display: flex; + gap: 8px; + font-size: 12px; +} + +.count-service-text { + color: #354154; + flex: 1; +} + +.count-service-prob { + color: #94a3b8; + font-variant-numeric: tabular-nums; + font-weight: 600; + min-width: 36px; + text-align: right; +} + +/* Empty state */ +.count-empty { + color: #94a3b8; + font-size: 13px; + padding: 28px 16px; + text-align: center; +} + +/* No results fallback */ +.count-no-results { + align-items: center; + color: #94a3b8; + display: flex; + flex-direction: column; + gap: 8px; + padding: 32px 16px; +} + +/* Progress enhancements */ +.count-current-file { + color: #2e7db2; + font-size: 12px; + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; +} + +.count-progress-right { + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.count-failed-label { color: #dc2626; } +.count-eta { color: #7b8698; } + +/* Footer valid badge */ +.count-valid-badge { + align-items: center; + background: rgba(255, 255, 255, 0.2); + border-radius: 999px; + display: inline-flex; + font-size: 11px; + font-weight: 700; + justify-content: center; + min-width: 20px; + padding: 1px 6px; +} + +/* wiz-check-label */ +.wiz-check-label { + align-items: center; + color: #354154; + cursor: pointer; + display: flex; + font-size: 13px; + font-weight: 400; + gap: 8px; +} + +.wiz-check-label input[type="checkbox"] { + min-height: unset; + width: auto; + cursor: pointer; +} + +/* ═══════════════════════════════════════════ + Tags input — filtro de conteos exactos + ═══════════════════════════════════════════ */ + +.count-tags-input { + align-items: center; + background: #f8fafc; + border: 1px solid #d5dde8; + border-radius: 8px; + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 40px; + padding: 6px 10px; + transition: border-color 0.15s ease; +} + +.count-tags-input:focus-within { + border-color: #2e7db2; + box-shadow: 0 0 0 2px rgba(46, 125, 178, 0.12); +} + +.count-tag { + align-items: center; + background: #2e7db2; + border-radius: 999px; + color: #ffffff; + display: inline-flex; + font-size: 12px; + font-weight: 700; + gap: 4px; + padding: 2px 10px 2px 10px; +} + +.count-tag-remove { + align-items: center; + background: rgba(255, 255, 255, 0.2); + border: 0; + border-radius: 999px; + color: #ffffff; + cursor: pointer; + display: inline-flex; + font-size: 13px; + font-weight: 700; + height: 16px; + justify-content: center; + line-height: 1; + padding: 0; + width: 16px; + transition: background 0.1s ease; +} + +.count-tag-remove:hover { background: rgba(255, 255, 255, 0.4); } + +.count-tag-input { + background: transparent; + border: 0; + color: #354154; + flex: 1; + font-size: 13px; + min-height: unset; + min-width: 140px; + padding: 0; + width: auto; +} + +.count-tag-input:focus { outline: none; } + +.count-tag-input::-webkit-inner-spin-button, +.count-tag-input::-webkit-outer-spin-button { -webkit-appearance: none; } + +.count-clear-btn { + background: transparent; + border: 0; + color: #7b8698; + cursor: pointer; + font-size: 12px; + padding: 0; + text-decoration: underline; + transition: color 0.15s ease; +} + +.count-clear-btn:hover { color: #dc2626; } + +/* Auto-ajuste nota */ +.count-auto-note { + align-items: center; + background: #fff8e1; + border: 1px solid #fcd34d; + border-radius: 6px; + color: #92400e; + display: flex; + font-size: 12.5px; + gap: 8px; + margin-top: 8px; + padding: 8px 12px; +} + +/* Auto-adjusted badge in row */ +.count-filename-row { + align-items: center; + display: flex; + gap: 6px; + min-width: 0; +} + +.count-filename-row .count-filename { + flex: 1; + min-width: 0; +} + +.count-auto-badge { + background: #fef3c7; + border: 1px solid #fcd34d; + border-radius: 4px; + color: #92400e; + flex-shrink: 0; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + padding: 1px 5px; + white-space: nowrap; +} + +/* ═══════════════════════════════════════════ + From-count banner (App.tsx) + ═══════════════════════════════════════════ */ + +.from-count-banner { + background: #eef6fd; + border: 1px solid #bfd9ef; + border-radius: 10px; + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; + padding: 14px 16px; +} + +.from-count-banner-header { + align-items: flex-start; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.from-count-banner-title { + color: #1a4d72; + font-size: 13.5px; + font-weight: 700; + margin: 0; +} + +.from-count-banner-sub { + color: #4a7fa8; + font-size: 12px; + margin: 3px 0 0; +} + +.from-count-banner-actions { + display: flex; + gap: 8px; +} + +/* ════════════════════════════════════════════ + Home + Job-centric UX + ════════════════════════════════════════════ */ + +/* workspace sin padding propio — las vistas gestionan su propio scroll */ +.workspace--full { + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; +} + +.workspace--full > .app-topbar + * { + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ── HomeView ── */ +.home-view { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.home-header { + align-items: center; + border-bottom: 1px solid var(--border); + display: flex; + flex-shrink: 0; + gap: 12px; + justify-content: space-between; + padding: 20px 24px 16px; +} + +.home-title { + font-size: 20px; + font-weight: 700; + margin: 0 0 2px; +} + +.home-subtitle { + color: var(--text-muted); + font-size: 13px; + margin: 0; +} + +.home-body { + flex: 1; + overflow-y: auto; + padding: 20px 24px; +} + +.home-loading { + align-items: center; + color: var(--text-muted); + display: flex; + gap: 10px; + justify-content: center; + padding: 48px 0; +} + +.home-empty { + align-items: center; + color: var(--text-muted); + display: flex; + flex-direction: column; + gap: 12px; + justify-content: center; + padding: 64px 0; + text-align: center; +} + +.home-empty p { + font-size: 15px; + margin: 0; +} + +/* ── Job list ── */ +.job-list { + display: flex; + flex-direction: column; + gap: 10px; + list-style: none; + margin: 0; + padding: 0; +} + +.job-card { + align-items: center; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + cursor: pointer; + display: flex; + gap: 14px; + padding: 14px 16px; + text-align: left; + transition: box-shadow 0.15s, border-color 0.15s; + width: 100%; +} + +.job-card:hover { + border-color: var(--primary); + box-shadow: 0 2px 8px rgba(0,0,0,0.08); +} + +.job-card-icon { + align-items: center; + background: var(--primary-ghost, rgba(59,130,246,0.08)); + border-radius: 8px; + color: var(--primary); + display: flex; + flex-shrink: 0; + height: 40px; + justify-content: center; + width: 40px; +} + +.job-card-body { + flex: 1; + min-width: 0; +} + +.job-card-title { + font-size: 14px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.job-card-path { + align-items: center; + color: var(--text-muted); + display: flex; + font-size: 11.5px; + gap: 4px; + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.job-card-meta { + align-items: center; + display: flex; + gap: 10px; + margin-top: 6px; +} + +.job-card-date { + align-items: center; + color: var(--text-muted); + display: flex; + font-size: 11.5px; + gap: 4px; +} + +.job-card-arrow { + color: var(--text-muted); + flex-shrink: 0; +} + +/* ── Modal ── */ +.modal-backdrop { + align-items: center; + background: rgba(15, 23, 36, 0.55); + bottom: 0; + display: flex; + inset: 0; + justify-content: center; + position: fixed; + z-index: 1000; +} + +.modal-card { + background: #ffffff; + border: 1px solid #d5dde8; + border-radius: 14px; + box-shadow: 0 16px 48px rgba(15, 23, 36, 0.18); + display: flex; + flex-direction: column; + max-width: 480px; + min-width: 360px; + overflow: hidden; + position: relative; + width: 90%; + z-index: 1001; +} + +.modal-header { + align-items: center; + background: #ffffff; + border-bottom: 1px solid #d5dde8; + display: flex; + justify-content: space-between; + padding: 16px 20px 14px; +} + +.modal-title { + font-size: 16px; + font-weight: 700; + margin: 0; +} + +.modal-close { + align-items: center; + background: transparent; + border: 0; + border-radius: 6px; + color: #5d697b; + cursor: pointer; + display: flex; + flex-shrink: 0; + height: 28px; + justify-content: center; + padding: 0; + width: 28px; +} + +.modal-close:hover { + background: #f1f5f9; + color: #354154; +} + +.modal-body { + background: #ffffff; + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; +} + +.modal-footer { + background: #f8fafc; + border-top: 1px solid #d5dde8; + display: flex; + gap: 8px; + justify-content: flex-end; + padding: 14px 20px; +} + +.form-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-label { + font-size: 13px; + font-weight: 600; +} + +.form-input { + background: var(--input-bg, var(--bg)); + border: 1px solid var(--border); + border-radius: 7px; + color: var(--text); + font-size: 14px; + outline: none; + padding: 8px 12px; + transition: border-color 0.15s; + width: 100%; +} + +.form-input:focus { + border-color: var(--primary); +} + +.form-hint { + color: var(--text-muted); + font-size: 11.5px; +} + +/* ── Sidebar: submenú de job ── */ +.side-job-back { + align-items: center; + background: none; + border: none; + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + display: flex; + font-size: 12.5px; + gap: 5px; + margin: 4px 8px 2px; + padding: 5px 8px; + text-align: left; + transition: background 0.15s, color 0.15s; +} + +.side-job-back:hover { + background: var(--border); + color: var(--text); +} + +.side-job-name { + color: var(--text); + font-size: 13px; + font-weight: 700; + margin: 4px 12px 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.side-section-divider { + background: var(--border); + height: 1px; + margin: 4px 10px; +} + +.side-nav--bottom { + margin-top: auto; +} + +/* ── JobView ── */ +.job-view { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.job-content-header { + align-items: center; + border-bottom: 1px solid var(--border); + display: flex; + flex-shrink: 0; + gap: 12px; + padding: 10px 16px; +} + +.job-content-header-section { + color: var(--text-muted); + font-size: 12.5px; + margin-left: auto; +} + +.job-section-content { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; + position: relative; +} + +/* Paneles de sección: permanecen montados pero ocultos para procesos en segundo plano */ +.job-section-pane { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.job-section-pane--hidden { + display: none; +} + +.job-background-banner-wrap { + align-items: center; + background: var(--surface-2, #f8fafc); + border-bottom: 1px solid var(--border); + display: flex; + flex-shrink: 0; + gap: 12px; + padding: 8px 16px; +} + +.job-background-banner-wrap .bg-progress-banner { + flex: 1; + margin: 0; +} + +/* Banner de progreso en segundo plano */ +.bg-progress-banner { + background: var(--surface-2, #f0f4ff); + border: 1px solid var(--border); + border-radius: 8px; + margin: 12px 16px; + padding: 12px 14px; +} + +.bg-progress-banner--compact { + border: none; + border-radius: 6px; + margin: 0; + padding: 6px 10px; +} + +.bg-progress-banner-header { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} + +.bg-progress-banner--compact .bg-progress-banner-header { + margin-bottom: 6px; +} + +.bg-progress-banner-title { + color: var(--text); + flex: 1; + font-size: 13px; + font-weight: 600; + min-width: 0; +} + +.bg-progress-banner-count { + color: var(--text-muted); + font-size: 12.5px; + white-space: nowrap; +} + +.bg-progress-banner-failed { + color: var(--danger, #dc2626); +} + +.bg-progress-banner-track { + height: 6px; +} + +.bg-progress-banner-file { + color: var(--text-muted); + font-size: 11.5px; + margin: 6px 0 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.results-live-progress { + border-bottom: 1px solid var(--border); + flex-shrink: 0; + padding-bottom: 4px; +} + +.results-live-hint { + color: var(--text-muted); + font-size: 12.5px; + margin: 0; + padding: 0 16px 12px; +} + +.results-section--live-only { + gap: 16px; + justify-content: center; + padding: 32px 16px; +} + +.results-section--live-only .bg-progress-banner { + margin: 0 auto; + max-width: 560px; + width: 100%; +} + +.results-section--live-only .results-live-hint { + margin: 0 auto; + max-width: 560px; + text-align: center; +} + +.job-back-btn { + color: var(--text-muted); + flex-shrink: 0; +} + +.job-title-area { + flex: 1; + min-width: 0; +} + +.job-title-btn { + align-items: center; + background: none; + border: none; + border-radius: 6px; + cursor: pointer; + display: flex; + gap: 6px; + padding: 4px 8px; + transition: background 0.15s; +} + +.job-title-btn:hover { + background: var(--border); +} + +.job-title-btn:hover .job-title-edit-icon { + opacity: 1; +} + +.job-title { + font-size: 16px; + font-weight: 700; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.job-title-edit-icon { + color: var(--text-muted); + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s; +} + +.job-title-edit { + align-items: center; + display: flex; + gap: 6px; +} + +.job-title-input { + font-size: 15px; + font-weight: 600; + max-width: 320px; +} + +/* ── Module tabs ── */ +.module-tabs { + border-bottom: 1px solid var(--border); + display: flex; + flex-shrink: 0; + gap: 2px; + overflow-x: auto; + padding: 6px 16px 0; +} + +.module-tab { + align-items: center; + background: none; + border: none; + border-bottom: 2px solid transparent; + border-radius: 6px 6px 0 0; + color: var(--text-muted); + cursor: pointer; + display: flex; + font-size: 13px; + font-weight: 500; + gap: 6px; + padding: 8px 14px 9px; + transition: color 0.15s, background 0.15s; + white-space: nowrap; +} + +.module-tab:hover { + background: var(--border); + color: var(--text); +} + +.module-tab--active { + border-bottom-color: var(--primary); + color: var(--primary); +} + +.module-tab--done .module-tab-check { + color: var(--success, #16a34a); + font-size: 11px; +} + +.module-tab-check { + display: inline; +} + +/* ── Module panel ── */ +.module-panel { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.module-loading { + align-items: center; + color: var(--text-muted); + display: flex; + flex-direction: column; + gap: 12px; + justify-content: center; + padding: 64px 0; +} + +/* ── Module results ── */ +.module-results { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.module-results-header { + align-items: center; + border-bottom: 1px solid var(--border); + display: flex; + flex-shrink: 0; + gap: 12px; + justify-content: space-between; + padding: 12px 16px; +} + +.module-results-title { + align-items: center; + display: flex; + gap: 8px; +} + +.module-results-title h3 { + font-size: 14px; + font-weight: 600; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.module-results-actions { + align-items: center; + display: flex; + flex-shrink: 0; + gap: 8px; +} + +/* ── Task badges ── */ +.task-badge { + align-items: center; + border-radius: 4px; + display: inline-flex; + font-size: 11px; + font-weight: 600; + gap: 4px; + padding: 2px 7px; + text-transform: uppercase; +} + +.task-badge--pending { background: #f3f4f6; color: #6b7280; } +.task-badge--running { background: #dbeafe; color: #1d4ed8; } +.task-badge--completed { background: #dcfce7; color: #15803d; } +.task-badge--failed { background: #fee2e2; color: #b91c1c; } + +/* ── Results table (extracción) ── */ +.results-table-wrapper { + flex: 1; + overflow: auto; + padding: 0 16px 16px; +} + +.results-table { + border-collapse: collapse; + font-size: 13px; + min-width: 600px; + width: 100%; +} + +.results-table th, +.results-table td { + border-bottom: 1px solid var(--border); + padding: 7px 10px; + text-align: left; + white-space: nowrap; +} + +.results-table th { + background: var(--surface); + color: var(--text-muted); + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.03em; + position: sticky; + text-transform: uppercase; + top: 0; + z-index: 1; +} + +.results-table tbody tr:hover td { + background: var(--bg); +} + +/* ── wiz step centered ── */ +.wiz-step-centered { + align-items: center; + display: flex; + flex-direction: column; + gap: 16px; + justify-content: center; + padding: 32px; + text-align: center; +} + +/* ── wiz hero ── */ +.wiz-hero { + align-items: center; + display: flex; + flex-direction: column; + gap: 10px; + text-align: center; +} + +.wiz-hero-icon { + align-items: center; + border-radius: 50%; + display: flex; + height: 56px; + justify-content: center; + width: 56px; +} + +.wiz-hero-icon--go { + background: #dbeafe; + color: var(--primary); +} + +.wiz-hero-title { + font-size: 18px; + font-weight: 700; + margin: 0; +} + +.wiz-found-badge { + align-items: center; + background: #dcfce7; + border-radius: 6px; + color: #15803d; + display: inline-flex; + font-size: 13px; + font-weight: 600; + gap: 6px; + margin-top: 6px; + padding: 5px 10px; +} + +@keyframes indeterminate { + 0% { transform: translateX(-100%); } + 50% { transform: translateX(0); } + 100% { transform: translateX(100%); } +} + +/* ── JobResultsView ── */ +.job-results-view { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.job-results-tabs { + align-items: center; + border-bottom: 1px solid var(--border); + display: flex; + flex-shrink: 0; + gap: 2px; + padding: 6px 16px 0; +} + +.job-results-tab { + align-items: center; + background: none; + border: none; + border-bottom: 2px solid transparent; + border-radius: 6px 6px 0 0; + color: var(--text-muted); + cursor: pointer; + display: flex; + font-size: 13px; + font-weight: 500; + gap: 6px; + padding: 8px 14px 9px; + transition: color 0.15s, background 0.15s; +} + +.job-results-tab:hover { + background: var(--border); + color: var(--text); +} + +.job-results-tab--active { + border-bottom-color: var(--primary); + color: var(--primary); +} + +.job-results-tab-count { + background: var(--border); + border-radius: 10px; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + padding: 1px 6px; +} + +.job-results-tab-count--live { + animation: pulse-live 1.5s ease infinite; + background: var(--primary); + color: #fff; +} + +@keyframes pulse-live { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +.job-results-content { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.job-results-empty { + align-items: center; + color: var(--text-muted); + display: flex; + flex-direction: column; + gap: 14px; + justify-content: center; + padding: 64px 0; + text-align: center; +} + +.job-results-empty p { + font-size: 14px; + margin: 0; +} + +.results-section { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.results-section-header { + align-items: center; + border-bottom: 1px solid var(--border); + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + gap: 10px; + justify-content: space-between; + padding: 10px 16px; +} + +.results-task-selector { + align-items: center; + display: flex; + gap: 8px; +} + +.results-section-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* ── Extracción desde conteo: wrapper y banner informativo ── */ +.job-extraction-wrapper { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.from-count-info-banner { + align-items: center; + background: #eff6ff; + border-bottom: 1px solid #bfdbfe; + color: #1e40af; + display: flex; + flex-shrink: 0; + font-size: 13px; + gap: 8px; + padding: 10px 16px; +} + +.from-count-info-banner strong { + font-weight: 700; +} + +.from-count-info-files { + align-items: center; + background: #1d4ed8; + border-radius: 20px; + color: #fff; + display: inline-flex; + font-size: 12px; + font-weight: 700; + gap: 4px; + margin-left: auto; + padding: 3px 10px; +} diff --git a/services/desktop_app/src/renderer/types/jobSection.ts b/services/desktop_app/src/renderer/types/jobSection.ts new file mode 100644 index 0000000..fb2469b --- /dev/null +++ b/services/desktop_app/src/renderer/types/jobSection.ts @@ -0,0 +1 @@ +export type JobSection = "counting" | "extraction" | "results"; diff --git a/services/desktop_app/src/renderer/views/HomeView.tsx b/services/desktop_app/src/renderer/views/HomeView.tsx new file mode 100644 index 0000000..f9c7266 --- /dev/null +++ b/services/desktop_app/src/renderer/views/HomeView.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showForm, setShowForm] = useState(false); + const [formTitle, setFormTitle] = useState(""); + const [formFolder, setFormFolder] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(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 ( +
+
+
+

Trabajos

+

Cada trabajo agrupa conteo y extracción de un lote de documentos.

+
+ +
+ + {/* ── Modal crear trabajo (portal: evita que fixed quede anclado a .home-view) ── */} + {showForm && createPortal( +
setShowForm(false)}> +
e.stopPropagation()}> +
+

Nuevo trabajo

+ +
+ +
+
+ + setFormTitle(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + autoFocus + /> +
+ +
+ +
+
+ {formFolder ? ( + <> + + {formFolder} + + ) : ( + Ninguna carpeta seleccionada + )} +
+ +
+ Se puede configurar después dentro del trabajo. +
+ + {createError && ( +
{createError}
+ )} +
+ +
+ + +
+
+
, + document.body, + )} + + {/* ── Lista de trabajos ── */} +
+ {loading ? ( +
+ + Cargando trabajos… +
+ ) : error ? ( +
+
+ {error} +
+ +
+ ) : jobs.length === 0 ? ( +
+ +

No hay trabajos todavía.

+ +
+ ) : ( +
    + {jobs.map((job) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/services/desktop_app/src/renderer/views/JobResultsView.tsx b/services/desktop_app/src/renderer/views/JobResultsView.tsx new file mode 100644 index 0000000..e4e3eed --- /dev/null +++ b/services/desktop_app/src/renderer/views/JobResultsView.tsx @@ -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; + onStartCounting: () => void; + onStartExtraction: (ctx?: Record) => void; +} + +export function JobResultsView({ + client, + job, + tasks, + countingProgress, + extractionProgress, + onRefresh, + onStartCounting, + onStartExtraction, +}: Props) { + const [activeTab, setActiveTab] = useState("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 ( +
+ {/* ── Sub-tabs: Conteo | Extracción ── */} +
+ + +
+ +
+ + {/* ── Contenido ── */} +
+ {activeTab === "counting" && ( + + onStartExtraction({ countingTaskId, validCounts, validCount }) + } + /> + )} + {activeTab === "extraction" && ( + onStartExtraction()} + /> + )} +
+
+ ); +} + +/* ══════════════════════════════════════════ + 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(tasks[0] ?? null); + const [files, setFiles] = useState([]); + const [loadingFiles, setLoadingFiles] = useState(false); + const [validCounts, setValidCounts] = useState([1]); + const [autoAdjustedFilenames] = useState>(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 ( +
+ +

No hay resultados de conteo todavía.

+ +
+ ); + } + + if (tasks.length === 0 && liveProgress?.running) { + return ( +
+ +

+ El conteo está en curso. Los resultados aparecerán aquí al terminar. + Puedes navegar libremente por la app. +

+
+ ); + } + + const validCount = files.filter(isFileValid).length; + + return ( +
+ {liveProgress?.running && ( +
+ +

+ Puedes cambiar de sección; el conteo continúa en segundo plano. +

+
+ )} + {/* Selector de ejecución + acciones */} +
+
+ {tasks.length > 1 && ( + + )} + {selectedTask && ( + {selectedTask.status} + )} +
+ +
+ + + + {selectedTask && validCount > 0 && ( + + )} +
+
+ + {loadingFiles ? ( +
+ ) : ( + + )} +
+ ); +} + +/* ══════════════════════════════════════════ + Sección de resultados de extracción +══════════════════════════════════════════ */ +type Row = Record; + +function ExtractionResultsSection({ + client, + tasks, + liveProgress, + onStartExtraction, +}: { + client: ApiClient; + tasks: Task[]; + liveProgress?: BackgroundProgress | null; + onStartExtraction: () => void; +}) { + const [selectedTask, setSelectedTask] = useState(tasks[0] ?? null); + const [rows, setRows] = useState([]); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [taskStatus, setTaskStatus] = useState("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 ( +
+ +

No hay resultados de extracción todavía.

+ +
+ ); + } + + if (tasks.length === 0 && liveProgress?.running) { + return ( +
+ +

+ Subiendo documentos. Puedes cambiar de sección; la carga continúa en segundo plano. +

+
+ ); + } + + const isRunning = taskStatus === "running" || taskStatus === "pending"; + + return ( +
+ {liveProgress?.running && ( +
+ +
+ )} +
+
+ {tasks.length > 1 && ( + + )} + {selectedTask && ( + + {isRunning && } + {taskStatus} + + )} +
+ +
+ {rows.length > 0 && ( + + )} + +
+
+ + {/* Error de red / API */} + {error && ( +
+ {error} +
+ )} + + {/* Error de la tarea (ej: archivos no encontrados) */} + {!error && selectedTask?.status === "failed" && selectedTask.error && ( +
+ La extracción falló: {selectedTask.error} +
+ )} + + {/* Advertencia si la tarea completó pero hay errores parciales */} + {!error && selectedTask?.status === "completed" && rows.length === 0 && ( +
+ + 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. +
+ )} + + {loading && rows.length === 0 ? ( +
+ ) : isRunning ? ( +
+ +

Extracción en progreso…

+
+
+
+
+
+
+ ) : rows.length === 0 ? ( +
+

No hay filas extraídas.

+
+ ) : ( +
+ + + {columns.map((c) => )} + + + {rows.map((row, i) => ( + + {columns.map((c) => ( + + ))} + + ))} + +
{c}
+ {row[c] === null || row[c] === undefined + ? — + : String(row[c])} +
+
+ )} +
+ ); +} diff --git a/services/desktop_app/src/renderer/views/JobView.tsx b/services/desktop_app/src/renderer/views/JobView.tsx new file mode 100644 index 0000000..a1072fc --- /dev/null +++ b/services/desktop_app/src/renderer/views/JobView.tsx @@ -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; + onActivateSection: (section: JobSection, context?: Record) => void; + onJobUpdated: (job: Job) => void; +} + +export function JobView({ client, job, section, sectionContext, onActivateSection, onJobUpdated }: Props) { + const [tasks, setTasks] = useState([]); + const [editingTitle, setEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(job.title ?? ""); + const [savingTitle, setSavingTitle] = useState(false); + const [countingProgress, setCountingProgress] = useState(null); + const [extractionProgress, setExtractionProgress] = useState(null); + const pollingRef = useRef | 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 ( +
+
+
+ {editingTitle ? ( +
+ setTitleDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") saveTitle(); + if (e.key === "Escape") setEditingTitle(false); + }} + autoFocus + /> + + +
+ ) : ( + + )} +
+
+ {section === "counting" && "Conteo de servicios"} + {section === "extraction" && "Extracción de datos"} + {section === "results" && "Resultados del trabajo"} +
+
+ + {/* Banner global visible en cualquier sección mientras corre un proceso */} + {activeBackground && section !== "results" && ( +
+ + +
+ )} + + {/* + Los módulos permanecen montados (solo ocultos) para que el conteo/subida + siga en segundo plano al cambiar de sección en el sidebar. + */} +
+
+ setCountingProgress(p.running ? p : null)} + onActivateModule={(type, ctx) => onActivateSection(type as JobSection, ctx)} + /> +
+ +
+ 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} + /> +
+ +
+ onActivateSection("counting")} + onStartExtraction={(ctx?: Record) => onActivateSection("extraction", ctx)} + /> +
+
+
+ ); +} diff --git a/services/desktop_app/src/shared/appVersion.ts b/services/desktop_app/src/shared/appVersion.ts new file mode 100644 index 0000000..502aabe --- /dev/null +++ b/services/desktop_app/src/shared/appVersion.ts @@ -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)}`; +} diff --git a/services/desktop_app/src/shared/types.ts b/services/desktop_app/src/shared/types.ts index 9a0a682..bbc3d67 100644 --- a/services/desktop_app/src/shared/types.ts +++ b/services/desktop_app/src/shared/types.ts @@ -69,6 +69,7 @@ export interface Task { result: Record; 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; 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 { diff --git a/services/desktop_app/vite.config.ts b/services/desktop_app/vite.config.ts index cdd4a47..55c8d6c 100644 --- a/services/desktop_app/vite.config.ts +++ b/services/desktop_app/vite.config.ts @@ -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), + }, }, -}); +})); diff --git a/services/extraction/api/main.py b/services/extraction/api/main.py index 8843952..7395b7f 100644 --- a/services/extraction/api/main.py +++ b/services/extraction/api/main.py @@ -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( diff --git a/services/extraction/infrastructure/database.py b/services/extraction/infrastructure/database.py index 9f9c155..f521832 100644 --- a/services/extraction/infrastructure/database.py +++ b/services/extraction/infrastructure/database.py @@ -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) diff --git a/services/services-count-facturas-medicas-serving/.dockerignore b/services/services-count-facturas-medicas-serving/.dockerignore new file mode 100644 index 0000000..6a0770f --- /dev/null +++ b/services/services-count-facturas-medicas-serving/.dockerignore @@ -0,0 +1,8 @@ +models/ +data/ +web/ +scripts/ +.git/ +*.md +Dockerfile.autoscaler +docker-compose.yml diff --git a/services/services-count-facturas-medicas-serving/Dockerfile b/services/services-count-facturas-medicas-serving/Dockerfile index d82fbfe..907e6d6 100644 --- a/services/services-count-facturas-medicas-serving/Dockerfile +++ b/services/services-count-facturas-medicas-serving/Dockerfile @@ -1,5 +1,3 @@ -# syntax=docker/dockerfile:1 - # ── Etapa 1: Descargar ONNX Runtime ─────────────────────────────────────────── FROM debian:bookworm-slim AS onnx-download diff --git a/services/services-count-facturas-medicas-serving/internal/api/server.go b/services/services-count-facturas-medicas-serving/internal/api/server.go index e0c4baf..67ea00a 100644 --- a/services/services-count-facturas-medicas-serving/internal/api/server.go +++ b/services/services-count-facturas-medicas-serving/internal/api/server.go @@ -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,