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,