feat: Bump version to 1.1.1 and implement new file handling features in the desktop application. Add MinIO download source picker, enhance extraction and counting wizards with support for downloading files, and improve styling for better user experience. Introduce unit tests for download manifest functionalities.

This commit is contained in:
erik
2026-07-17 09:32:18 -05:00
parent dc3fcc2280
commit 447359166b
17 changed files with 1914 additions and 387 deletions
+25
View File
@@ -103,11 +103,36 @@ ipcMain.handle("dialog:save-csv", async (_event, defaultPath) => {
return result.filePath;
});
ipcMain.handle("dialog:save-file", async (_event, defaultPath, filters) => {
const result = await dialog.showSaveDialog({
defaultPath: defaultPath || "documento.pdf",
filters: Array.isArray(filters) && filters.length > 0
? filters
: [{ name: "PDF", extensions: ["pdf"] }],
});
if (result.canceled || !result.filePath) return null;
return result.filePath;
});
ipcMain.handle("file:write-text", async (_event, filePath, content) => {
await fs.writeFile(filePath, content, "utf8");
return { ok: true, filePath };
});
ipcMain.handle("file:write-bytes", async (_event, filePath, data) => {
const buffer = Buffer.isBuffer(data)
? data
: Buffer.from(data instanceof ArrayBuffer ? data : new Uint8Array(data));
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, buffer);
return { ok: true, filePath };
});
ipcMain.handle("path:join", async (_event, parts) => {
const list = Array.isArray(parts) ? parts.map(String) : [];
return path.join(...list);
});
ipcMain.handle("fs:first-document", async (_event, rootPath, keywords) => {
const matches = await findDocuments(rootPath, Array.isArray(keywords) ? keywords : []);
return matches[0] || null;
@@ -4,7 +4,10 @@ contextBridge.exposeInMainWorld("desktopApi", {
getRuntimeConfig: () => ipcRenderer.invoke("runtime:config"),
selectDirectory: () => ipcRenderer.invoke("dialog:select-directory"),
saveCsvDialog: (defaultPath) => ipcRenderer.invoke("dialog:save-csv", defaultPath),
saveFileDialog: (defaultPath, filters) => ipcRenderer.invoke("dialog:save-file", defaultPath, filters),
writeTextFile: (filePath, content) => ipcRenderer.invoke("file:write-text", filePath, content),
writeBytesFile: (filePath, data) => ipcRenderer.invoke("file:write-bytes", filePath, data),
joinPath: (...parts) => ipcRenderer.invoke("path:join", parts),
findFirstDocument: (rootPath, keywords) => ipcRenderer.invoke("fs:first-document", rootPath, keywords),
indexFolder: (rootPath, structureMode, docTypes, keywords) =>
ipcRenderer.invoke("fs:index-folder", rootPath, structureMode, docTypes, keywords),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ipsextractor-desktop",
"version": "1.1.0",
"version": "1.1.1",
"private": true,
"description": "Desktop extraction client for IPSExtractor microservices.",
"main": "electron/main.cjs",
@@ -19,14 +19,20 @@ import type {
CountingFormState,
CreateCountJobPayload,
DocumentType,
DownloadJobFile,
Job,
ManifestEntry,
Task,
} from "../../shared/types";
import { ApiClient } from "../lib/apiClient";
import { downloadCsv, toCsv, buildExportCsvFilename } from "../lib/csv";
import { countFlatFolders, downloadFilesToManifest } from "../lib/downloadManifest";
import { CountResultsTable } from "./CountResultsTable";
import { CountingStepper, type CountingStep } from "./CountingStepper";
import {
MinioDownloadSourcePicker,
type DocumentSourceMode,
} from "./MinioDownloadSourcePicker";
declare const desktopApi: import("../../shared/types").DesktopBridge;
@@ -36,7 +42,12 @@ interface Props {
containerRoot: string;
/** Job al que pertenece este wizard (para vincular la tarea en la BD) */
job?: Job;
onStartExtraction: (countingTaskId: string, validCount: number, validCounts?: number[]) => void;
onStartExtraction: (
countingTaskId: string,
validCount: number,
validCounts?: number[],
downloadJobId?: string | null,
) => 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. */
@@ -59,32 +70,6 @@ const BATCH_SIZE = 10;
/** Peticiones HTTP simultáneas al count-api. */
const BATCH_CONCURRENCY = 2;
function downloadFilesToManifest(
files: Array<{ key: string; nit?: string | null; radicado?: string | null }>,
): ManifestEntry[] {
return files
.filter((f) => f.key.toLowerCase().endsWith(".pdf"))
.map((f) => {
const filename = f.key.split("/").pop() || f.key;
const radicado =
(f.radicado || "").trim() ||
filename.replace(/\.pdf$/i, "");
return {
radicado,
filename,
localPath: f.key,
size: 0,
nit: f.nit ?? null,
objectKey: f.key,
};
})
.sort((a, b) => {
const nitCmp = String(a.nit ?? "").localeCompare(String(b.nit ?? ""), undefined, { numeric: true });
if (nitCmp !== 0) return nitCmp;
return a.radicado.localeCompare(b.radicado, undefined, { numeric: true });
});
}
function formatEta(progress: CountProgress): string | null {
if (!progress.startedAt || progress.processed === 0) return null;
const elapsed = (Date.now() - progress.startedAt) / 1000;
@@ -108,7 +93,9 @@ export function CountingWizard({
downloadJobId,
downloadFiles,
}: Props) {
const fromDownload = Boolean(downloadJobId);
const [source, setSource] = useState<DocumentSourceMode>(downloadJobId ? "minio" : "local");
const [selectedDownloadJobId, setSelectedDownloadJobId] = useState<string | undefined>(downloadJobId);
const fromDownload = source === "minio";
const [step, setStep] = useState<CountingStep>(1);
const [maxReached, setMaxReached] = useState<CountingStep>(1);
const [form, setForm] = useState<CountingFormState>({
@@ -159,31 +146,56 @@ export function CountingWizard({
[docTypes],
);
const indexFromDownload = useCallback(
async (jobId: string, seedFiles?: Array<{ key: string; nit?: string | null; radicado?: string | null }>) => {
const handleMinioIndexed = useCallback(
async (jobId: string, files: DownloadJobFile[]) => {
downloadJobIdRef.current = jobId;
setSelectedDownloadJobId(jobId);
setIndexing(true);
setError(null);
try {
let files = seedFiles ?? [];
if (files.length === 0) {
const resp = await client.listDownloadJobFiles(jobId);
files = resp.files;
let resolved = files;
if (
resolved.length === 0 &&
downloadJobId &&
jobId === downloadJobId &&
downloadFiles &&
downloadFiles.length > 0
) {
resolved = downloadFiles;
}
const entries = downloadFilesToManifest(files);
if (resolved.length === 0) {
const resp = await client.listDownloadJobFiles(jobId);
resolved = resp.files;
}
const entries = downloadFilesToManifest(resolved);
setManifest(entries);
if (entries.length === 0) {
setError("No se encontraron PDF en MinIO para esta corrida de descarga.");
}
return entries;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setManifest([]);
return [];
setError(e instanceof Error ? e.message : String(e));
} finally {
setIndexing(false);
}
},
[client],
[client, downloadJobId, downloadFiles],
);
const handleSourceChange = useCallback(
(next: DocumentSourceMode) => {
setSource(next);
setError(null);
if (next === "local") {
downloadJobIdRef.current = undefined;
setSelectedDownloadJobId(undefined);
setManifest([]);
if (form.localRoot) void indexLocalFolder(form.localRoot);
} else {
setManifest([]);
}
},
[form.localRoot, indexLocalFolder],
);
useEffect(() => {
@@ -195,12 +207,13 @@ export function CountingWizard({
}, [localRoot, containerRoot]);
useEffect(() => {
downloadJobIdRef.current = downloadJobId;
if (downloadJobId) {
void indexFromDownload(downloadJobId, downloadFiles);
setSource("minio");
setSelectedDownloadJobId(downloadJobId);
downloadJobIdRef.current = downloadJobId;
return;
}
if (!localRoot) return;
if (source !== "local" || !localRoot) return;
let cancelled = false;
indexLocalFolder(localRoot).catch(() => {
if (!cancelled) setError("Error al indexar la carpeta.");
@@ -208,9 +221,8 @@ export function CountingWizard({
return () => {
cancelled = true;
};
// Solo reindexar al cambiar de corrida/carpeta; seed files es opcional.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localRoot, downloadJobId, indexLocalFolder, indexFromDownload]);
}, [localRoot, downloadJobId, indexLocalFolder]);
function goToStep(target: CountingStep) {
setStep(target);
@@ -470,7 +482,7 @@ export function CountingWizard({
function handleStartExtraction() {
if (!savedTask) return;
const validCount = savedFiles.filter(isFileValid).length;
onStartExtraction(savedTask.id, validCount, form.validCounts);
onStartExtraction(savedTask.id, validCount, form.validCounts, downloadJobIdRef.current ?? null);
}
function handleExportCsv() {
@@ -503,8 +515,8 @@ export function CountingWizard({
setAutoAdjustedFilenames(new Set());
setError(null);
setProgress({ processed: 0, total: 0, failed: 0, currentBatch: "", startedAt: 0 });
if (downloadJobIdRef.current) {
void indexFromDownload(downloadJobIdRef.current, downloadFiles);
if (source === "minio" && downloadJobIdRef.current) {
void handleMinioIndexed(downloadJobIdRef.current, []);
} else if (form.localRoot) {
void indexLocalFolder(form.localRoot);
}
@@ -529,77 +541,88 @@ export function CountingWizard({
<div className="wiz-section">
<h3 className="wiz-section-title">Origen de las facturas</h3>
<p className="wiz-desc">
{fromDownload
? "Los PDF se leen directamente desde MinIO (todas las carpetas NIT de la corrida de descarga)."
: "Selecciona la carpeta local con los PDF de facturas médicas. El servicio contará cuántos servicios contiene cada documento usando OCR."}
Elige carpeta local o una corrida MinIO del bot de descarga. El servicio contará
cuántos servicios contiene cada documento usando OCR.
</p>
{job?.id ? (
<MinioDownloadSourcePicker
client={client}
parentJobId={job.id}
source={source}
onSourceChange={handleSourceChange}
selectedDownloadJobId={selectedDownloadJobId}
onSelectedDownloadJobIdChange={setSelectedDownloadJobId}
onIndexed={handleMinioIndexed}
indexing={indexing}
indexedFileCount={manifest.length}
indexedFolderCount={countFlatFolders(manifest)}
disabled={running}
localContent={
<div className="wiz-section" style={{ marginTop: 16 }}>
<label className="wiz-field-label">Carpeta a procesar</label>
<div className="folder-picker">
<div className="folder-picker-display">
{form.localRoot ? (
<>
<FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path">{form.localRoot}</span>
</>
) : (
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span>
)}
</div>
<button type="button" className="btn btn-primary" onClick={selectFolder} disabled={indexing}>
{indexing ? <Loader2 size={14} className="spin" /> : <FolderOpen size={15} />}
{indexing ? "Indexando…" : "Explorar"}
</button>
</div>
{manifest.length > 0 && !indexing && source === "local" && (
<div className="wiz-found-badge">
<FileText size={15} />
<span>
<strong>{manifest.length}</strong> documento(s) encontrados y listos para contar
</span>
</div>
)}
{form.localRoot && manifest.length === 0 && !indexing && !error && source === "local" && (
<p className="wiz-hint">No se encontraron PDF en esta carpeta.</p>
)}
</div>
}
/>
) : (
<div className="wiz-section">
<label className="wiz-field-label">Carpeta a procesar</label>
<div className="folder-picker">
<div className="folder-picker-display">
{form.localRoot ? (
<>
<FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path">{form.localRoot}</span>
</>
) : (
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span>
)}
</div>
<button type="button" className="btn btn-primary" onClick={selectFolder} disabled={indexing}>
{indexing ? <Loader2 size={14} className="spin" /> : <FolderOpen size={15} />}
{indexing ? "Indexando…" : "Explorar"}
</button>
</div>
{manifest.length > 0 && !indexing && (
<div className="wiz-found-badge">
<FileText size={15} />
<span>
<strong>{manifest.length}</strong> documento(s) encontrados y listos para contar
</span>
</div>
)}
</div>
)}
</div>
{fromDownload ? (
<div className="wiz-section">
<label className="wiz-field-label">Corrida MinIO</label>
<div className="folder-picker">
<div className="folder-picker-display">
<FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path">
downloads/{downloadJobId}
</span>
</div>
<button
type="button"
className="btn btn-ghost"
onClick={() => downloadJobId && void indexFromDownload(downloadJobId)}
disabled={indexing}
>
{indexing ? <Loader2 size={14} className="spin" /> : <RotateCcw size={14} />}
{indexing ? "Indexando…" : "Recargar"}
</button>
</div>
{manifest.length > 0 && !indexing && (
<div className="wiz-found-badge">
<FileText size={15} />
<span>
<strong>{manifest.length}</strong> PDF(s) en{" "}
<strong>{new Set(manifest.map((m) => m.nit).filter(Boolean)).size || 1}</strong>{" "}
carpeta(s) planas — listos para contar
</span>
</div>
)}
</div>
) : (
<div className="wiz-section">
<label className="wiz-field-label">Carpeta a procesar</label>
<div className="folder-picker">
<div className="folder-picker-display">
{form.localRoot ? (
<>
<FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path">{form.localRoot}</span>
</>
) : (
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span>
)}
</div>
<button type="button" className="btn btn-primary" onClick={selectFolder} disabled={indexing}>
{indexing ? <Loader2 size={14} className="spin" /> : <FolderOpen size={15} />}
{indexing ? "Indexando…" : "Explorar"}
</button>
</div>
{manifest.length > 0 && !indexing && (
<div className="wiz-found-badge">
<FileText size={15} />
<span>
<strong>{manifest.length}</strong> documento(s) encontrados y listos para contar
</span>
</div>
)}
{form.localRoot && manifest.length === 0 && !indexing && !error && (
<p className="wiz-hint">No se encontraron PDF en esta carpeta.</p>
)}
</div>
)}
<details className="wiz-advanced">
<summary className="wiz-advanced-trigger">Opciones avanzadas</summary>
<div className="wiz-advanced-body">
@@ -20,10 +20,20 @@ import {
Zap,
} from "lucide-react";
import { useState } from "react";
import type { DocumentType, ExtractionFormState, FieldSelection, ManifestEntry, SchemaResponse } from "../../shared/types";
import type {
DocumentType,
DownloadJobFile,
ExtractionFormState,
FieldSelection,
ManifestEntry,
SchemaResponse,
} from "../../shared/types";
import type { ApiClient } from "../lib/apiClient";
import { countFlatFolders } from "../lib/downloadManifest";
import { SchemaFieldPicker } from "./SchemaFieldPicker";
import { SampleTreeView } from "./SampleTreeView";
import { ExtractionStepper, type ExtractionStep } from "./ExtractionStepper";
import { MinioDownloadSourcePicker, type DocumentSourceMode } from "./MinioDownloadSourcePicker";
interface ExtractionWizardProps {
step: ExtractionStep;
@@ -64,6 +74,20 @@ interface ExtractionWizardProps {
effectiveDocumentCount?: number;
/** Etiqueta descriptiva para el origen de los documentos (ej. "desde conteo") */
documentSourceLabel?: string;
/** Permite elegir corrida MinIO en el paso 1 (extracción sin conteo). */
allowMinioSource?: boolean;
docSource?: DocumentSourceMode;
onDocSourceChange?: (source: DocumentSourceMode) => void;
selectedDownloadJobId?: string;
onSelectedDownloadJobIdChange?: (id: string | undefined) => void;
onMinioIndexed?: (downloadJobId: string, files: DownloadJobFile[]) => void | Promise<void>;
minioIndexing?: boolean;
client?: ApiClient;
parentJobId?: string;
/** Multi-NIT: páginas de selección (una por carpeta plana). */
nitPages?: Array<{ nit: string; fieldCount: number; selectedCount: number; docCount: number }>;
selectNitIndex?: number;
onSelectNitIndexChange?: (index: number) => void;
}
const MODELS = [
@@ -83,6 +107,14 @@ interface EstimateResult {
extractor_model?: string;
normalizer_model?: string;
pages_per_doc?: number;
estimate_mode?: string;
multi_nit_breakdown?: Array<{
nit: string;
docs: number;
fields: number;
total_usd: number;
per_doc_usd: number;
}>;
}
function fmtUsd(val: number | undefined): string {
@@ -130,12 +162,61 @@ export function ExtractionWizard({
onNext,
effectiveDocumentCount,
documentSourceLabel,
allowMinioSource = false,
docSource = "local",
onDocSourceChange,
selectedDownloadJobId,
onSelectedDownloadJobIdChange,
onMinioIndexed,
minioIndexing = false,
client,
parentJobId,
nitPages,
selectNitIndex = 0,
onSelectNitIndexChange,
}: 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;
const multiNitSelect = Boolean(nitPages && nitPages.length > 1);
const activeNitPage = multiNitSelect ? nitPages![selectNitIndex] : undefined;
const totalSelectedFields = multiNitSelect
? nitPages!.reduce((acc, p) => acc + p.selectedCount, 0)
: selection.fields.length;
const localFolderSection = (
<div className="wiz-section" style={allowMinioSource ? { marginTop: 16 } : undefined}>
<label className="wiz-field-label">Carpeta a procesar</label>
<div className="folder-picker">
<div className="folder-picker-display">
{form.selectedLocalPath ? (
<>
<FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path">{form.selectedLocalPath}</span>
</>
) : (
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span>
)}
</div>
<button type="button" className="btn btn-primary" onClick={selectRoot} disabled={busy || minioIndexing}>
<FolderOpen size={15} /> Explorar
</button>
</div>
{manifest.length > 0 && docSource === "local" && (
<div className="wiz-found-badge">
<FileText size={15} />
<span><strong>{manifest.length}</strong> documento(s) encontrados y listos para procesar</span>
</div>
)}
{form.mode === "general" && docSource === "local" && (
<p className="wiz-hint">Se procesaran todos los PDF y TIFF encontrados en la carpeta.</p>
)}
</div>
);
return (
<div className="wiz">
@@ -148,7 +229,7 @@ export function ExtractionWizard({
<div className="wiz-section">
<h3 className="wiz-section-title">Origen de los documentos</h3>
<p className="wiz-desc">
Elige como estan organizados tus archivos y selecciona la carpeta que contiene los documentos a procesar.
Elige como estan organizados tus archivos y selecciona carpeta local o una corrida MinIO.
</p>
<div className="mode-switch">
@@ -156,7 +237,7 @@ export function ExtractionWizard({
type="button"
className={form.mode === "radicados" ? "active" : ""}
onClick={() => onModeChange("radicados")}
disabled={busy}
disabled={busy || docSource === "minio"}
>
<Layers size={15} />
Por radicados
@@ -171,38 +252,33 @@ export function ExtractionWizard({
Carpeta general
</button>
</div>
</div>
<div className="wiz-section">
<label className="wiz-field-label">Carpeta a procesar</label>
<div className="folder-picker">
<div className="folder-picker-display">
{form.selectedLocalPath ? (
<>
<FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path">{form.selectedLocalPath}</span>
</>
) : (
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span>
)}
</div>
<button type="button" className="btn btn-primary" onClick={selectRoot} disabled={busy}>
<FolderOpen size={15} /> Explorar
</button>
</div>
{manifest.length > 0 && (
<div className="wiz-found-badge">
<FileText size={15} />
<span><strong>{manifest.length}</strong> documento(s) encontrados y listos para procesar</span>
</div>
)}
{form.mode === "general" && (
<p className="wiz-hint">Se procesaran todos los PDF y TIFF encontrados en la carpeta.</p>
{docSource === "minio" && (
<p className="wiz-hint">
Origen MinIO usa carpetas planas por NIT (modo general). El descubrimiento toma una muestra
por carpeta y unifica el schema.
</p>
)}
</div>
{allowMinioSource && client && parentJobId && onDocSourceChange && onMinioIndexed ? (
<MinioDownloadSourcePicker
client={client}
parentJobId={parentJobId}
source={docSource}
onSourceChange={onDocSourceChange}
selectedDownloadJobId={selectedDownloadJobId}
onSelectedDownloadJobIdChange={onSelectedDownloadJobIdChange}
onIndexed={onMinioIndexed}
indexing={minioIndexing || busy}
indexedFileCount={manifest.length}
indexedFolderCount={countFlatFolders(manifest)}
disabled={busy}
localContent={localFolderSection}
/>
) : (
localFolderSection
)}
{form.mode === "radicados" && (
<div className="wiz-section">
<label className="wiz-field-label">Tipos de documento</label>
@@ -422,10 +498,43 @@ export function ExtractionWizard({
{step === 3 && (
<div className="wiz-step wiz-step-split">
<div className="wiz-split-main">
{multiNitSelect && activeNitPage && (
<div className="nit-select-pager">
<div className="nit-select-pager-meta">
<strong>
Selección {selectNitIndex + 1}/{nitPages!.length}
</strong>
<span>
NIT {activeNitPage.nit} · {activeNitPage.docCount} PDF ·{" "}
{activeNitPage.selectedCount}/{activeNitPage.fieldCount} campos
</span>
</div>
<div className="nit-select-dots" role="tablist" aria-label="Carpetas NIT">
{nitPages!.map((p, i) => (
<button
key={p.nit}
type="button"
role="tab"
aria-selected={i === selectNitIndex}
className={`nit-select-dot${i === selectNitIndex ? " nit-select-dot--active" : ""}${
p.selectedCount > 0 ? " nit-select-dot--done" : ""
}`}
title={`NIT ${p.nit}`}
onClick={() => onSelectNitIndexChange?.(i)}
/>
))}
</div>
</div>
)}
<div className="wiz-section-header">
<h3 className="wiz-section-title">
Campos a extraer
<span className="wiz-count-badge">{selection.fields.length} / {fieldCount}</span>
{multiNitSelect && activeNitPage ? (
<span className="wiz-count-badge">NIT {activeNitPage.nit}</span>
) : null}
<span className="wiz-count-badge">
{selection.fields.length} / {fieldCount}
</span>
</h3>
<div className="wiz-toolbar">
<div className="wiz-search-box">
@@ -468,7 +577,9 @@ export function ExtractionWizard({
<div className="wiz-rediscover-text">
<strong>¿Algo no salió como esperabas?</strong>
<span>
Reintenta el descubrimiento con otro archivo aleatorio
{multiNitSelect
? "Reintenta el descubrimiento solo para este NIT con otro archivo."
: "Reintenta el descubrimiento con otro archivo aleatorio"}
{discoverySampleLabels.length > 0 && (
<> (ahora: <em>{discoverySampleLabels.join(", ")}</em>)</>
)}
@@ -515,7 +626,9 @@ export function ExtractionWizard({
</div>
<h3 className="wiz-hero-title">Listo para extraer</h3>
<p className="wiz-desc">
Revisa el resumen y lanza la extraccion. Los archivos se suben uno a uno sin guardarse en el servidor.
{multiNitSelect
? "Se extraerá cada NIT con sus campos; al final verás un solo CSV con columnas alineadas por nombre."
: "Revisa el resumen y lanza la extraccion. Los archivos se suben uno a uno sin guardarse en el servidor."}
</p>
</div>
@@ -523,9 +636,24 @@ export function ExtractionWizard({
<div className="wiz-review-item">
<FolderOpen size={18} />
<div>
<span className="wiz-review-label">Carpeta</span>
<span className="wiz-review-value" title={form.selectedLocalPath}>
{form.selectedLocalPath || "—"}
<span className="wiz-review-label">
{docSource === "minio" ? "Origen MinIO" : "Carpeta"}
</span>
<span
className="wiz-review-value"
title={
docSource === "minio"
? selectedDownloadJobId
? `downloads/${selectedDownloadJobId}`
: "—"
: form.selectedLocalPath
}
>
{docSource === "minio"
? selectedDownloadJobId
? `downloads/${selectedDownloadJobId.slice(0, 8)}…`
: "—"
: form.selectedLocalPath || "—"}
</span>
</div>
</div>
@@ -543,26 +671,57 @@ export function ExtractionWizard({
<div className="wiz-review-item">
<Database size={18} />
<div>
<span className="wiz-review-label">Campos seleccionados</span>
<span className="wiz-review-value">{selection.fields.length}</span>
<span className="wiz-review-label">
{multiNitSelect ? "Campos (suma NIT)" : "Campos seleccionados"}
</span>
<span className="wiz-review-value">{totalSelectedFields}</span>
</div>
</div>
<div className="wiz-review-item">
<Layers size={18} />
<div>
<span className="wiz-review-label">Modo</span>
<span className="wiz-review-value">{form.mode === "radicados" ? "Por radicados" : "General"}</span>
<span className="wiz-review-value">
{multiNitSelect
? `${nitPages!.length} NIT secuenciales`
: form.mode === "radicados"
? "Por radicados"
: "General"}
</span>
</div>
</div>
</div>
{multiNitSelect && (
<div className="nit-exec-summary">
{nitPages!.map((p) => (
<div key={p.nit} className="nit-exec-summary-row">
<strong>NIT {p.nit}</strong>
<span>
{p.docCount} PDF · {p.selectedCount} campos
</span>
</div>
))}
</div>
)}
<div className="wiz-action-group">
<button type="button" className="btn btn-outline" onClick={estimate} disabled={busy || selection.fields.length === 0}>
<button
type="button"
className="btn btn-outline"
onClick={estimate}
disabled={busy || totalSelectedFields === 0}
>
{busy ? <Loader2 className="spin" size={15} /> : <Database size={15} />} Estimar costo
</button>
<button type="button" className="btn btn-primary btn-lg" onClick={runExtraction} disabled={busy || selection.fields.length === 0}>
<button
type="button"
className="btn btn-primary btn-lg"
onClick={runExtraction}
disabled={busy || totalSelectedFields === 0}
>
{busy ? <Loader2 className="spin" size={18} /> : <Play size={18} />}
Ejecutar extraccion
{multiNitSelect ? "Ejecutar por NIT" : "Ejecutar extraccion"}
</button>
</div>
@@ -581,14 +740,43 @@ export function ExtractionWizard({
const normPrompt = normUsage?.prompt_tokens ?? 0;
const normCompl = normUsage?.completion_tokens ?? 0;
const pages = est.pages_per_doc ?? 1;
const multiBreakdown = est.multi_nit_breakdown;
const isMultiEst = est.estimate_mode === "multi_nit" && Boolean(multiBreakdown?.length);
return (
<div className="est-card">
<div className="est-card-header">
<Database size={15} />
<span>Estimado de costo — muestra de 1 documento × {docs}</span>
<span>
{isMultiEst
? `Estimado multi-NIT — ${multiBreakdown!.length} carpetas · ${docs} docs`
: `Estimado de costo — muestra de 1 documento × ${docs}`}
</span>
</div>
{isMultiEst && (
<div className="est-nit-breakdown">
{multiBreakdown!.map((p) => (
<div key={p.nit} className="est-nit-breakdown-row">
<span>
<strong>NIT {p.nit}</strong>
<span className="est-nit-breakdown-meta">
{" "}
· {p.docs} PDF · {p.fields} campo{p.fields === 1 ? "" : "s"}
</span>
</span>
<span className="est-nit-breakdown-cost">
{fmtUsd(p.total_usd)}
<span className="est-nit-breakdown-per">
{" "}
({fmtUsd(p.per_doc_usd)}/doc)
</span>
</span>
</div>
))}
</div>
)}
<div className="est-phases">
{/* Extractor */}
<div className="est-phase">
@@ -600,7 +788,9 @@ export function ExtractionWizard({
<span className="est-token-row">
<span className="est-token-label">Entrada</span>
<span className="est-token-val">{fmtTokens(extPrompt)} tok</span>
<span className="est-token-hint">{pages} pág(s) imagen + prompt</span>
<span className="est-token-hint">
{isMultiEst ? "muestra ref." : `${pages} pág(s) imagen + prompt`}
</span>
</span>
{extCached > 0 && (
<span className="est-token-row">
@@ -641,15 +831,22 @@ export function ExtractionWizard({
<div className="est-totals">
<div className="est-total-row">
<span className="est-total-label">Por documento</span>
<span className="est-total-label">
{isMultiEst ? "Promedio por documento" : "Por documento"}
</span>
<span className="est-total-val">{fmtUsd(perDoc?.total_usd)}</span>
</div>
<div className="est-total-row est-total-row--big">
<span className="est-total-label">Total estimado ({docs} doc{docs !== 1 ? "s" : ""})</span>
<span className="est-total-label">
Total estimado ({docs} doc{docs !== 1 ? "s" : ""}
{isMultiEst ? ` · ${multiBreakdown!.length} NIT` : ""})
</span>
<span className="est-total-val est-total-grand">{fmtUsd(total?.total_usd)}</span>
</div>
<p className="est-disclaimer">
Estimado basado en 1 documento de muestra. El costo real puede variar según el numero de páginas y la complejidad del contenido.
{isMultiEst
? "Suma de un estimado por NIT (muestra × docs de esa carpeta, con sus campos). El costo real puede variar."
: "Estimado basado en 1 documento de muestra. El costo real puede variar según el numero de páginas y la complejidad del contenido."}
</p>
</div>
</div>
@@ -704,7 +901,9 @@ export function ExtractionWizard({
</div>
{step < 4 ? (
<button type="button" className="btn btn-primary" onClick={onNext} disabled={busy || !canGoNext}>
Siguiente
{step === 3 && multiNitSelect && selectNitIndex < (nitPages?.length ?? 1) - 1
? "Siguiente NIT"
: "Siguiente"}
</button>
) : (
<button type="button" className="btn btn-primary" onClick={runExtraction} disabled={busy || selection.fields.length === 0}>
@@ -0,0 +1,216 @@
import { AlertCircle, Cloud, FolderOpen, Loader2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import type { DownloadJob, DownloadJobFile } from "../../shared/types";
import type { ApiClient } from "../lib/apiClient";
export type DocumentSourceMode = "local" | "minio";
interface Props {
client: ApiClient;
/** Job Desktop contenedor (para filtrar corridas del bot). */
parentJobId: string;
source: DocumentSourceMode;
onSourceChange: (source: DocumentSourceMode) => void;
/** Corrida MinIO preseleccionada (p.ej. desde Resultados → Continuar a conteo). */
selectedDownloadJobId?: string;
onSelectedDownloadJobIdChange?: (id: string | undefined) => void;
/** Tras listar PDFs de la corrida. */
onIndexed: (downloadJobId: string, files: DownloadJobFile[]) => void | Promise<void>;
/** Contenido del origen local (picker de carpeta). */
localContent: ReactNode;
indexing?: boolean;
indexedFileCount?: number;
indexedFolderCount?: number;
disabled?: boolean;
/** Etiqueta corta del toggle MinIO. */
minioLabel?: string;
}
function jobLabel(r: DownloadJob): string {
const title = r.title || r.original_filename || r.id.slice(0, 8);
return `${title} · ${r.status} · ${r.worker_id}`;
}
export function MinioDownloadSourcePicker({
client,
parentJobId,
source,
onSourceChange,
selectedDownloadJobId,
onSelectedDownloadJobIdChange,
onIndexed,
localContent,
indexing = false,
indexedFileCount = 0,
indexedFolderCount = 0,
disabled = false,
minioLabel = "Corrida MinIO",
}: Props) {
const [runs, setRuns] = useState<DownloadJob[]>([]);
const [loadingRuns, setLoadingRuns] = useState(false);
const [listing, setListing] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadRuns = useCallback(async () => {
setLoadingRuns(true);
setError(null);
try {
const all = await client.listDownloadJobs(50, parentJobId);
const completed = all.filter((j) => j.status === "completed");
setRuns(completed);
if (completed.length === 0) {
setError("No hay corridas de bot de descarga completadas en este trabajo.");
}
return completed;
} catch (e) {
setRuns([]);
setError(e instanceof Error ? e.message : "No se pudieron listar las corridas MinIO.");
return [];
} finally {
setLoadingRuns(false);
}
}, [client, parentJobId]);
const indexJob = useCallback(
async (jobId: string) => {
setListing(true);
setError(null);
try {
const resp = await client.listDownloadJobFiles(jobId);
const files = resp.files.filter((f) => f.key.toLowerCase().endsWith(".pdf"));
if (files.length === 0) {
setError("No se encontraron PDF en MinIO para esta corrida de descarga.");
}
await onIndexed(jobId, files);
} catch (e) {
setError(e instanceof Error ? e.message : "Error al listar archivos MinIO.");
await onIndexed(jobId, []);
} finally {
setListing(false);
}
},
[client, onIndexed],
);
useEffect(() => {
if (source !== "minio") return;
let cancelled = false;
void (async () => {
const completed = await loadRuns();
if (cancelled) return;
const preferred =
(selectedDownloadJobId && completed.find((j) => j.id === selectedDownloadJobId)?.id) ||
completed[0]?.id;
if (preferred && preferred !== selectedDownloadJobId) {
onSelectedDownloadJobIdChange?.(preferred);
}
if (preferred) {
await indexJob(preferred);
}
})();
return () => {
cancelled = true;
};
// Solo al entrar en modo MinIO / cambiar job contenedor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [source, parentJobId]);
const busy = disabled || indexing || listing || loadingRuns;
return (
<div className="minio-source-picker">
<div className="mode-switch" role="group" aria-label="Origen de documentos">
<button
type="button"
className={source === "local" ? "active" : ""}
onClick={() => onSourceChange("local")}
disabled={disabled}
>
<FolderOpen size={15} />
Carpeta local
</button>
<button
type="button"
className={source === "minio" ? "active" : ""}
onClick={() => onSourceChange("minio")}
disabled={disabled}
>
<Cloud size={15} />
{minioLabel}
</button>
</div>
{source === "local" ? (
localContent
) : (
<div className="wiz-section" style={{ marginTop: 16 }}>
<label className="wiz-field-label">Corrida de descarga (MinIO)</label>
<p className="wiz-hint" style={{ marginTop: 0, marginBottom: 12 }}>
Se leen los PDF de todas las carpetas planas (NIT) de la corrida seleccionada.
</p>
<div className="folder-picker">
<select
className="form-input"
value={selectedDownloadJobId ?? ""}
disabled={busy || runs.length === 0}
onChange={(e) => {
const id = e.target.value || undefined;
onSelectedDownloadJobIdChange?.(id);
if (id) void indexJob(id);
}}
style={{ flex: 1, minWidth: 0 }}
>
{runs.length === 0 ? (
<option value="">Sin corridas completadas</option>
) : (
runs.map((r) => (
<option key={r.id} value={r.id}>
{jobLabel(r)}
</option>
))
)}
</select>
<button
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={() => {
void (async () => {
const completed = await loadRuns();
const id =
(selectedDownloadJobId &&
completed.find((j) => j.id === selectedDownloadJobId)?.id) ||
completed[0]?.id;
if (id) {
onSelectedDownloadJobIdChange?.(id);
await indexJob(id);
}
})();
}}
>
{busy ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
{busy ? "Cargando…" : "Recargar"}
</button>
</div>
{indexedFileCount > 0 && !busy && (
<div className="wiz-found-badge">
<Cloud size={15} />
<span>
<strong>{indexedFileCount}</strong> PDF(s) en{" "}
<strong>{indexedFolderCount || 1}</strong> carpeta(s) planas — listos para procesar
</span>
</div>
)}
</div>
)}
{error && source === "minio" && (
<div className="wiz-toast wiz-toast--error" style={{ position: "static", marginTop: 12 }}>
<AlertCircle size={15} />
<span>{error}</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { countFlatFolders, downloadFilesToManifest } from "./downloadManifest";
describe("downloadFilesToManifest", () => {
it("maps PDF keys to manifest with nit and objectKey", () => {
const entries = downloadFilesToManifest([
{ key: "downloads/j1/9001/rad-a.pdf", nit: "9001", radicado: "rad-a" },
{ key: "downloads/j1/9002/rad-b.pdf", nit: "9002", radicado: null },
{ key: "downloads/j1/9001/ignore.txt", nit: "9001" },
]);
expect(entries).toHaveLength(2);
expect(entries[0]).toMatchObject({
nit: "9001",
filename: "rad-a.pdf",
radicado: "rad-a",
objectKey: "downloads/j1/9001/rad-a.pdf",
});
expect(entries[1]?.nit).toBe("9002");
expect(entries[1]?.radicado).toBe("rad-b");
});
it("counts flat NIT folders", () => {
expect(countFlatFolders([])).toBe(0);
expect(countFlatFolders([{ nit: "1" }, { nit: "1" }, { nit: "2" }])).toBe(2);
expect(countFlatFolders([{ nit: null }, { nit: "" }])).toBe(1);
});
});
@@ -0,0 +1,31 @@
import type { DownloadJobFile, ManifestEntry } from "../../shared/types";
/** Convierte archivos listados de una corrida MinIO a ManifestEntry (carpetas planas NIT). */
export function downloadFilesToManifest(files: DownloadJobFile[]): ManifestEntry[] {
return files
.filter((f) => f.key.toLowerCase().endsWith(".pdf"))
.map((f) => {
const filename = f.key.split("/").pop() || f.key;
const radicado = (f.radicado || "").trim() || filename.replace(/\.pdf$/i, "");
return {
radicado,
filename,
localPath: f.key,
size: 0,
nit: f.nit ?? null,
objectKey: f.key,
};
})
.sort((a, b) => {
const nitCmp = String(a.nit ?? "").localeCompare(String(b.nit ?? ""), undefined, {
numeric: true,
});
if (nitCmp !== 0) return nitCmp;
return a.radicado.localeCompare(b.radicado, undefined, { numeric: true });
});
}
export function countFlatFolders(entries: { nit?: string | null }[]): number {
const nits = new Set(entries.map((e) => (e.nit || "").trim()).filter(Boolean));
return nits.size || (entries.length > 0 ? 1 : 0);
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { mergeExtractionRows } from "./mergeExtractionRows";
describe("mergeExtractionRows", () => {
it("aligns columns by name and fills missing with empty string", () => {
const { columns, rows } = mergeExtractionRows([
[
{ nit: "1", radicado: "a", Ciudad: "Bogota", NIT: "111" },
{ nit: "1", radicado: "b", Ciudad: "Cali", NIT: "111" },
],
[{ nit: "2", radicado: "c", Folio: "F1", NIT: "222" }],
]);
expect(columns[0]).toBe("nit");
expect(columns[1]).toBe("radicado");
expect(columns).toContain("Ciudad");
expect(columns).toContain("Folio");
expect(columns).toContain("NIT");
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({ nit: "1", Ciudad: "Bogota", Folio: "" });
expect(rows[2]).toMatchObject({ nit: "2", Folio: "F1", Ciudad: "" });
});
it("handles empty sets", () => {
expect(mergeExtractionRows([])).toEqual({ columns: [], rows: [] });
expect(mergeExtractionRows([[]])).toEqual({ columns: [], rows: [] });
});
});
@@ -0,0 +1,51 @@
export type ExtractionRow = Record<string, unknown>;
const SKIP_COLS = new Set(["_id", "batch_id"]);
/**
* Fusiona filas de varias extracciones (p.ej. una por NIT) en un solo set
* de columnas alineadas por nombre. Celdas ausentes → "".
*/
export function mergeExtractionRows(
rowSets: ExtractionRow[][],
options?: { preferNitFirst?: boolean },
): { columns: string[]; rows: ExtractionRow[] } {
const preferNit = options?.preferNitFirst !== false;
const seen = new Set<string>();
const encounter: string[] = [];
for (const set of rowSets) {
for (const row of set) {
for (const key of Object.keys(row)) {
if (!key || SKIP_COLS.has(key) || seen.has(key)) continue;
seen.add(key);
encounter.push(key);
}
}
}
let columns: string[];
if (preferNit) {
const rest = encounter.filter((c) => c !== "nit" && c !== "radicado");
columns = [];
if (seen.has("nit")) columns.push("nit");
if (seen.has("radicado")) columns.push("radicado");
columns.push(...rest);
} else {
columns = encounter;
}
const rows: ExtractionRow[] = [];
for (const set of rowSets) {
for (const row of set) {
const normalized: ExtractionRow = {};
for (const col of columns) {
const val = row[col];
normalized[col] = val === undefined || val === null ? "" : val;
}
rows.push(normalized);
}
}
return { columns, rows };
}
@@ -0,0 +1,82 @@
/** Helpers para guardar PDFs desde MinIO en el desktop Electron (con fallback blob). */
declare const desktopApi: import("../../shared/types").DesktopBridge | undefined;
function pdfBasename(key: string, radicado?: string | null): string {
const fromRad = (radicado || "").trim();
if (fromRad) return fromRad.toLowerCase().endsWith(".pdf") ? fromRad : `${fromRad}.pdf`;
const base = key.split("/").pop() || "documento.pdf";
return base.toLowerCase().endsWith(".pdf") ? base : `${base}.pdf`;
}
function sanitizeFolderName(name: string): string {
return name.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").trim() || "sin-nit";
}
/** Guarda un PDF con diálogo «Guardar como» (o descarga blob si no hay Electron). */
export async function savePdfAs(filename: string, data: ArrayBuffer): Promise<boolean> {
const safeName = pdfBasename(filename);
if (typeof desktopApi?.saveFileDialog === "function" && typeof desktopApi?.writeBytesFile === "function") {
const dest = await desktopApi.saveFileDialog(safeName, [{ name: "PDF", extensions: ["pdf"] }]);
if (!dest) return false;
await desktopApi.writeBytesFile(dest, data);
return true;
}
const blob = new Blob([data], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = safeName;
a.click();
URL.revokeObjectURL(url);
return true;
}
export type BulkPdfItem = {
key: string;
nit?: string | null;
radicado?: string | null;
};
/**
* Guarda varios PDFs en una carpeta elegida por el usuario, organizados por NIT.
* Retorna cuántos se escribieron; null si el usuario canceló el diálogo.
*/
export async function savePdfsToDirectory(
items: BulkPdfItem[],
fetchBytes: (key: string) => Promise<ArrayBuffer>,
onProgress?: (done: number, total: number, label: string) => void,
): Promise<{ saved: number; failed: number } | null> {
if (typeof desktopApi?.selectDirectory !== "function" || typeof desktopApi?.writeBytesFile !== "function") {
throw new Error("La descarga masiva requiere la app de escritorio.");
}
const dir = await desktopApi.selectDirectory();
if (!dir) return null;
let saved = 0;
let failed = 0;
const total = items.length;
for (let i = 0; i < items.length; i++) {
const item = items[i]!;
const label = item.radicado || item.key.split("/").pop() || item.key;
onProgress?.(i, total, String(label));
try {
const data = await fetchBytes(item.key);
const nitFolder = sanitizeFolderName((item.nit || "").trim() || "Sin NIT");
const filename = pdfBasename(item.key, item.radicado);
const dest =
typeof desktopApi.joinPath === "function"
? await desktopApi.joinPath(dir, nitFolder, filename)
: `${dir}\\${nitFolder}\\${filename}`;
await desktopApi.writeBytesFile(dest, data);
saved += 1;
} catch {
failed += 1;
}
}
onProgress?.(total, total, "Listo");
return { saved, failed };
}
export { pdfBasename };
@@ -36,13 +36,13 @@ export function CountingWizardModule({
downloadFiles={downloadFiles}
onTaskCreated={onTaskCreated}
onProgress={onProgress}
onStartExtraction={(countingTaskId, validCount, validCounts) => {
onStartExtraction={(countingTaskId, validCount, validCounts, downloadJobIdFromWizard) => {
onActivateModule?.("extraction", {
countingTaskId,
validCount,
validCounts,
jobId: job.id,
downloadJobId,
downloadJobId: downloadJobIdFromWizard ?? downloadJobId,
});
}}
/>
@@ -3,43 +3,102 @@ import { useEffect, useState } from "react";
import type { ExtractionBatch } from "../../../shared/types";
import { ExtractionResultsTable } from "../../components/ExtractionResultsTable";
import { downloadCsv, toCsv, buildExportCsvFilename } from "../../lib/csv";
import { mergeExtractionRows, type ExtractionRow } from "../../lib/mergeExtractionRows";
import type { ModuleResultsProps } from "../registry";
type Row = Record<string, unknown>;
type Props = ModuleResultsProps & {
/** Tasks hijas de extracción multi-NIT (incluida la principal). */
childTaskIds?: string[];
};
function isActiveStatus(status: string | undefined): boolean {
return status === "running" || status === "pending";
}
export function ExtractionResults({
client,
task,
job,
onActivateModule,
childTaskIds,
}: Props) {
const taskIds =
childTaskIds && childTaskIds.length > 0
? childTaskIds
: (() => {
const fromResult = (task.result as Record<string, unknown> | undefined)?.child_task_ids;
return Array.isArray(fromResult) && fromResult.every((x) => typeof x === "string")
? (fromResult as string[])
: [task.id];
})();
const multi = taskIds.length > 1;
export function ExtractionResults({ client, task, job, onActivateModule }: ModuleResultsProps) {
const [batch, setBatch] = useState<ExtractionBatch | null>(null);
const [rows, setRows] = useState<Row[]>([]);
const [rows, setRows] = useState<ExtractionRow[]>([]);
const [columns, setColumns] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [polling, setPolling] = useState<ReturnType<typeof setInterval> | null>(null);
const [doneCount, setDoneCount] = useState(0);
function stop() {
if (polling !== null) { clearInterval(polling); setPolling(null); }
if (polling !== null) {
clearInterval(polling);
setPolling(null);
}
}
async function load() {
try {
const t = await client.task(task.id);
const tasks = await Promise.all(taskIds.map((id) => client.task(id)));
const anyRunning = tasks.some((t) => isActiveStatus(t.status));
const allDone = tasks.every((t) => !isActiveStatus(t.status));
setDoneCount(tasks.filter((t) => !isActiveStatus(t.status)).length);
const primary = tasks.find((t) => t.id === task.id) ?? tasks[0]!;
const pseudoBatch: ExtractionBatch = {
id: String((t.result as Record<string, unknown>)?.extraction_batch_id ?? t.id),
status: t.status as ExtractionBatch["status"],
id: String((primary.result as Record<string, unknown>)?.extraction_batch_id ?? primary.id),
status: anyRunning ? "running" : (primary.status as ExtractionBatch["status"]),
root_path: "",
request: {},
created_at: t.created_at,
updated_at: t.updated_at,
created_at: primary.created_at,
updated_at: primary.updated_at,
};
setBatch(pseudoBatch);
if (t.status === "running" || t.status === "pending") return;
if (!allDone) return;
stop();
const response = await client.taskRows(t.id, 500, 0);
const data = response.rows ?? [];
setRows(data);
const cols = Array.from(new Set(data.flatMap((r: Record<string, unknown>) => Object.keys(r)))).filter(
(c) => !["_id", "batch_id"].includes(c),
) as string[];
setColumns(cols);
const rowSets: ExtractionRow[][] = [];
for (const t of tasks) {
const response = await client.taskRows(t.id, 2000, 0);
const data = (response.rows ?? []) as ExtractionRow[];
// Añadir nit desde request de la task si falta en la fila
const req = (t.request || {}) as Record<string, unknown>;
const nitHint =
typeof req.nit === "string"
? req.nit
: typeof (t.result as Record<string, unknown> | undefined)?.nit === "string"
? String((t.result as Record<string, unknown>).nit)
: "";
rowSets.push(
data.map((r) => {
if (nitHint && (r.nit === undefined || r.nit === null || r.nit === "")) {
return { ...r, nit: nitHint };
}
return r;
}),
);
}
const merged = multi ? mergeExtractionRows(rowSets) : mergeExtractionRows(rowSets);
setRows(merged.rows);
setColumns(merged.columns);
if (onActivateModule) {
/* keep parent in sync with primary */
}
} catch (e) {
setError(String(e));
stop();
@@ -53,7 +112,8 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul
const id = setInterval(load, 5000);
setPolling(id);
return () => clearInterval(id);
}, [task.id]); // eslint-disable-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [task.id, taskIds.join("|")]);
function exportCsv() {
const csv = toCsv(
@@ -65,9 +125,7 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul
function startCleaning() {
const batchId = String(
(task.result as Record<string, unknown>)?.extraction_batch_id
?? batch?.id
?? "",
(task.result as Record<string, unknown>)?.extraction_batch_id ?? batch?.id ?? "",
);
onActivateModule?.("cleaning", {
extractionBatchId: batchId || undefined,
@@ -89,7 +147,13 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul
return (
<div className="wiz-toast wiz-toast--error" style={{ margin: 16 }}>
{error}
<button className="btn btn-sm" onClick={() => { setError(null); load(); }}>
<button
className="btn btn-sm"
onClick={() => {
setError(null);
void load();
}}
>
<RefreshCw size={13} /> Reintentar
</button>
</div>
@@ -102,12 +166,11 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul
<div className="module-results">
<div className="module-results-header">
<div className="module-results-title">
<h3>{task.title}</h3>
<span
className={`task-badge task-badge--${batch?.status ?? "pending"}`}
>
<h3>{multi ? `${job.title || "Extracción"} · ${taskIds.length} NIT` : task.title}</h3>
<span className={`task-badge task-badge--${batch?.status ?? "pending"}`}>
{isRunning && <Loader2 size={11} className="spin" />}
{batch?.status ?? "pendiente"}
{multi && isRunning ? ` · ${doneCount}/${taskIds.length}` : ""}
</span>
</div>
<div style={{ display: "flex", gap: 8 }}>
@@ -116,22 +179,34 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul
<Download size={13} /> Descargar CSV ({rows.length} filas)
</button>
)}
{rows.length > 0 && !isRunning && onActivateModule && (
{rows.length > 0 && !isRunning && onActivateModule && !multi && (
<button type="button" className="btn btn-sm btn-primary" onClick={startCleaning}>
<Sparkles size={13} /> Limpiar datos <ArrowRight size={13} />
</button>
)}
{rows.length > 0 && !isRunning && onActivateModule && multi && (
<button type="button" className="btn btn-sm btn-primary" onClick={startCleaning}>
<Sparkles size={13} /> Limpiar (1.er NIT) <ArrowRight size={13} />
</button>
)}
</div>
</div>
{isRunning && (
<div className="wiz-progress" style={{ margin: "0 16px 12px" }}>
<div className="wiz-progress-header">
<span>Extracción en progreso…</span>
<span>
{multi
? `Extracción multi-NIT en progreso (${doneCount}/${taskIds.length} listas)…`
: "Extracción en progreso…"}
</span>
<Loader2 size={13} className="spin" />
</div>
<div className="wiz-progress-track">
<div className="wiz-progress-fill" style={{ width: "100%", animation: "indeterminate 1.4s ease infinite" }} />
<div
className="wiz-progress-fill"
style={{ width: "100%", animation: "indeterminate 1.4s ease infinite" }}
/>
</div>
</div>
)}
File diff suppressed because it is too large Load Diff
+323 -1
View File
@@ -1984,6 +1984,15 @@ th {
gap: 10px;
}
.folder-picker .form-input {
flex: 1;
min-width: 0;
}
.minio-source-picker {
margin-top: 4px;
}
.folder-picker-display {
align-items: center;
background: #f8fafc;
@@ -4237,7 +4246,8 @@ th {
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
}
.results-section-header {
@@ -4264,6 +4274,318 @@ th {
gap: 6px;
}
.results-section-header-left,
.results-section-header-right {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.results-empty {
align-items: center;
color: var(--muted, #64748b);
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
justify-content: center;
padding: 48px 24px;
text-align: center;
}
/* ── Resultados bot descarga: multi-NIT + descargas ── */
.dl-files-section {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 12px;
min-height: 0;
}
.dl-files-header {
align-items: center;
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
gap: 10px;
justify-content: space-between;
}
.dl-files-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dl-prefix-code {
font-size: 11px;
word-break: break-all;
}
.dl-bulk-progress {
background: var(--surface-2, #f1f5f9);
border: 1px solid var(--border, #e2e8f0);
border-radius: 10px;
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 8px;
margin-bottom: 12px;
padding: 10px 12px;
}
.dl-bulk-progress-meta {
align-items: center;
color: var(--text, #334155);
display: flex;
font-size: 13px;
gap: 8px;
}
.dl-nit-list {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 10px;
max-height: min(420px, 50vh);
overflow: auto;
padding-right: 2px;
}
.dl-nit-card {
background: var(--surface, #fff);
border: 1px solid var(--border, #e2e8f0);
border-radius: 12px;
flex-shrink: 0;
overflow: hidden;
}
.dl-nit-card--open {
border-color: color-mix(in srgb, var(--primary, #2563eb) 35%, var(--border, #e2e8f0));
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
}
.dl-nit-card-header {
align-items: center;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
display: flex;
flex-shrink: 0;
gap: 8px;
justify-content: space-between;
min-height: 44px;
padding: 4px 8px 4px 4px;
}
.dl-nit-card-toggle {
align-items: center;
background: transparent;
border: none;
color: inherit;
cursor: pointer;
display: flex;
flex: 1;
font: inherit;
gap: 8px;
min-height: 36px;
min-width: 0;
padding: 8px 10px;
text-align: left;
}
.dl-nit-card-toggle:hover {
background: rgb(15 23 42 / 3%);
border-radius: 8px;
}
.dl-nit-card-icon {
color: var(--primary, #2563eb);
flex-shrink: 0;
}
.dl-nit-card-title {
font-size: 13px;
font-weight: 650;
letter-spacing: 0.01em;
}
.dl-nit-card-count {
background: #e2e8f0;
border-radius: 999px;
color: #475569;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
}
.dl-nit-card-body {
border-top: 1px solid var(--border, #e2e8f0);
max-height: 240px;
overflow: auto;
}
.dl-files-table {
border-collapse: collapse;
font-size: 12.5px;
width: 100%;
}
.dl-files-table th,
.dl-files-table td {
border-bottom: 1px solid var(--border, #eef2f7);
padding: 8px 12px;
text-align: left;
vertical-align: middle;
}
.dl-files-table th {
background: #f8fafc;
color: #64748b;
font-size: 11px;
font-weight: 650;
letter-spacing: 0.03em;
position: sticky;
text-transform: uppercase;
top: 0;
z-index: 1;
}
.dl-files-table tbody tr:hover td {
background: #f8fafc;
}
.dl-col-radicado {
font-variant-numeric: tabular-nums;
white-space: nowrap;
width: 40%;
}
.dl-col-file {
color: #475569;
max-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dl-files-table-actions {
text-align: right;
white-space: nowrap;
width: 1%;
}
.dl-log-box {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
max-height: 200px;
overflow: auto;
}
.nit-select-pager {
align-items: center;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
border: 1px solid var(--border, #e2e8f0);
border-radius: 10px;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: space-between;
margin-bottom: 12px;
padding: 10px 12px;
}
.nit-select-pager-meta {
display: flex;
flex-direction: column;
font-size: 13px;
gap: 2px;
}
.nit-select-pager-meta span {
color: #64748b;
font-size: 12px;
}
.nit-select-dots {
align-items: center;
display: flex;
gap: 6px;
}
.nit-select-dot {
background: #cbd5e1;
border: none;
border-radius: 999px;
cursor: pointer;
height: 10px;
padding: 0;
width: 10px;
}
.nit-select-dot--done {
background: #93c5fd;
}
.nit-select-dot--active {
background: var(--primary, #2e7db2);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary, #2e7db2) 25%, transparent);
height: 12px;
width: 12px;
}
.nit-exec-summary {
background: #f8fafc;
border: 1px solid var(--border, #e2e8f0);
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 6px;
margin: 0 auto 16px;
max-width: 420px;
padding: 10px 14px;
width: 100%;
}
.nit-exec-summary-row {
display: flex;
font-size: 13px;
justify-content: space-between;
gap: 12px;
}
.nit-exec-summary-row span {
color: #64748b;
}
.est-nit-breakdown {
border-bottom: 1px solid var(--border, #e2e8f0);
display: flex;
flex-direction: column;
gap: 6px;
margin: 0 0 12px;
padding: 0 0 12px;
}
.est-nit-breakdown-row {
display: flex;
font-size: 12.5px;
gap: 12px;
justify-content: space-between;
}
.est-nit-breakdown-meta,
.est-nit-breakdown-per {
color: #64748b;
font-weight: 400;
}
.est-nit-breakdown-cost {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
font-weight: 600;
}
/* ── Extracción desde conteo: wrapper y banner informativo ── */
.job-extraction-wrapper {
display: flex;
@@ -7,7 +7,10 @@ beforeEach(() => {
getRuntimeConfig: vi.fn().mockResolvedValue({ documentRootLocal: "C:\\docs", documentRootContainer: "/data" }),
selectDirectory: vi.fn(),
saveCsvDialog: vi.fn(),
saveFileDialog: vi.fn(),
writeTextFile: vi.fn(),
writeBytesFile: vi.fn(),
joinPath: vi.fn(async (...parts: string[]) => parts.join("\\")),
findFirstDocument: vi.fn(),
indexFolder: vi.fn().mockResolvedValue([
{ radicado: "R1", filename: "factura.pdf", docType: "Factura", localPath: "C:\\docs\\R1\\factura.pdf", size: 10 },
+9
View File
@@ -373,7 +373,16 @@ export interface DesktopBridge {
getRuntimeConfig(): Promise<RuntimeConfig>;
selectDirectory(): Promise<string | null>;
saveCsvDialog(defaultPath?: string): Promise<string | null>;
saveFileDialog(
defaultPath?: string,
filters?: Array<{ name: string; extensions: string[] }>,
): Promise<string | null>;
writeTextFile(filePath: string, content: string): Promise<{ ok: true; filePath: string }>;
writeBytesFile(
filePath: string,
data: ArrayBuffer | Uint8Array,
): Promise<{ ok: true; filePath: string }>;
joinPath(...parts: string[]): Promise<string>;
findFirstDocument(rootPath: string, keywords: string[]): Promise<string | null>;
indexFolder(
rootPath: string,