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; 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) => { ipcMain.handle("file:write-text", async (_event, filePath, content) => {
await fs.writeFile(filePath, content, "utf8"); await fs.writeFile(filePath, content, "utf8");
return { ok: true, filePath }; 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) => { ipcMain.handle("fs:first-document", async (_event, rootPath, keywords) => {
const matches = await findDocuments(rootPath, Array.isArray(keywords) ? keywords : []); const matches = await findDocuments(rootPath, Array.isArray(keywords) ? keywords : []);
return matches[0] || null; return matches[0] || null;
@@ -4,7 +4,10 @@ contextBridge.exposeInMainWorld("desktopApi", {
getRuntimeConfig: () => ipcRenderer.invoke("runtime:config"), getRuntimeConfig: () => ipcRenderer.invoke("runtime:config"),
selectDirectory: () => ipcRenderer.invoke("dialog:select-directory"), selectDirectory: () => ipcRenderer.invoke("dialog:select-directory"),
saveCsvDialog: (defaultPath) => ipcRenderer.invoke("dialog:save-csv", defaultPath), 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), 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), findFirstDocument: (rootPath, keywords) => ipcRenderer.invoke("fs:first-document", rootPath, keywords),
indexFolder: (rootPath, structureMode, docTypes, keywords) => indexFolder: (rootPath, structureMode, docTypes, keywords) =>
ipcRenderer.invoke("fs:index-folder", rootPath, structureMode, docTypes, keywords), ipcRenderer.invoke("fs:index-folder", rootPath, structureMode, docTypes, keywords),
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ipsextractor-desktop", "name": "ipsextractor-desktop",
"version": "1.1.0", "version": "1.1.1",
"private": true, "private": true,
"description": "Desktop extraction client for IPSExtractor microservices.", "description": "Desktop extraction client for IPSExtractor microservices.",
"main": "electron/main.cjs", "main": "electron/main.cjs",
@@ -19,14 +19,20 @@ import type {
CountingFormState, CountingFormState,
CreateCountJobPayload, CreateCountJobPayload,
DocumentType, DocumentType,
DownloadJobFile,
Job, Job,
ManifestEntry, ManifestEntry,
Task, Task,
} from "../../shared/types"; } from "../../shared/types";
import { ApiClient } from "../lib/apiClient"; import { ApiClient } from "../lib/apiClient";
import { downloadCsv, toCsv, buildExportCsvFilename } from "../lib/csv"; import { downloadCsv, toCsv, buildExportCsvFilename } from "../lib/csv";
import { countFlatFolders, downloadFilesToManifest } from "../lib/downloadManifest";
import { CountResultsTable } from "./CountResultsTable"; import { CountResultsTable } from "./CountResultsTable";
import { CountingStepper, type CountingStep } from "./CountingStepper"; import { CountingStepper, type CountingStep } from "./CountingStepper";
import {
MinioDownloadSourcePicker,
type DocumentSourceMode,
} from "./MinioDownloadSourcePicker";
declare const desktopApi: import("../../shared/types").DesktopBridge; declare const desktopApi: import("../../shared/types").DesktopBridge;
@@ -36,7 +42,12 @@ interface Props {
containerRoot: string; containerRoot: string;
/** Job al que pertenece este wizard (para vincular la tarea en la BD) */ /** Job al que pertenece este wizard (para vincular la tarea en la BD) */
job?: Job; 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) */ /** Callback cuando se guarda la tarea en BD (para el módulo de JobView) */
onTaskCreated?: (task: Task) => void; onTaskCreated?: (task: Task) => void;
/** Callback de progreso en segundo plano, para mostrarlo desde otras secciones. */ /** 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. */ /** Peticiones HTTP simultáneas al count-api. */
const BATCH_CONCURRENCY = 2; 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 { function formatEta(progress: CountProgress): string | null {
if (!progress.startedAt || progress.processed === 0) return null; if (!progress.startedAt || progress.processed === 0) return null;
const elapsed = (Date.now() - progress.startedAt) / 1000; const elapsed = (Date.now() - progress.startedAt) / 1000;
@@ -108,7 +93,9 @@ export function CountingWizard({
downloadJobId, downloadJobId,
downloadFiles, downloadFiles,
}: Props) { }: 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 [step, setStep] = useState<CountingStep>(1);
const [maxReached, setMaxReached] = useState<CountingStep>(1); const [maxReached, setMaxReached] = useState<CountingStep>(1);
const [form, setForm] = useState<CountingFormState>({ const [form, setForm] = useState<CountingFormState>({
@@ -159,31 +146,56 @@ export function CountingWizard({
[docTypes], [docTypes],
); );
const indexFromDownload = useCallback( const handleMinioIndexed = useCallback(
async (jobId: string, seedFiles?: Array<{ key: string; nit?: string | null; radicado?: string | null }>) => { async (jobId: string, files: DownloadJobFile[]) => {
downloadJobIdRef.current = jobId;
setSelectedDownloadJobId(jobId);
setIndexing(true); setIndexing(true);
setError(null); setError(null);
try { try {
let files = seedFiles ?? []; let resolved = files;
if (files.length === 0) { if (
const resp = await client.listDownloadJobFiles(jobId); resolved.length === 0 &&
files = resp.files; 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); setManifest(entries);
if (entries.length === 0) { if (entries.length === 0) {
setError("No se encontraron PDF en MinIO para esta corrida de descarga."); setError("No se encontraron PDF en MinIO para esta corrida de descarga.");
} }
return entries;
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(e));
setManifest([]); setManifest([]);
return []; setError(e instanceof Error ? e.message : String(e));
} finally { } finally {
setIndexing(false); 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(() => { useEffect(() => {
@@ -195,12 +207,13 @@ export function CountingWizard({
}, [localRoot, containerRoot]); }, [localRoot, containerRoot]);
useEffect(() => { useEffect(() => {
downloadJobIdRef.current = downloadJobId;
if (downloadJobId) { if (downloadJobId) {
void indexFromDownload(downloadJobId, downloadFiles); setSource("minio");
setSelectedDownloadJobId(downloadJobId);
downloadJobIdRef.current = downloadJobId;
return; return;
} }
if (!localRoot) return; if (source !== "local" || !localRoot) return;
let cancelled = false; let cancelled = false;
indexLocalFolder(localRoot).catch(() => { indexLocalFolder(localRoot).catch(() => {
if (!cancelled) setError("Error al indexar la carpeta."); if (!cancelled) setError("Error al indexar la carpeta.");
@@ -208,9 +221,8 @@ export function CountingWizard({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
// Solo reindexar al cambiar de corrida/carpeta; seed files es opcional.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [localRoot, downloadJobId, indexLocalFolder, indexFromDownload]); }, [localRoot, downloadJobId, indexLocalFolder]);
function goToStep(target: CountingStep) { function goToStep(target: CountingStep) {
setStep(target); setStep(target);
@@ -470,7 +482,7 @@ export function CountingWizard({
function handleStartExtraction() { function handleStartExtraction() {
if (!savedTask) return; if (!savedTask) return;
const validCount = savedFiles.filter(isFileValid).length; const validCount = savedFiles.filter(isFileValid).length;
onStartExtraction(savedTask.id, validCount, form.validCounts); onStartExtraction(savedTask.id, validCount, form.validCounts, downloadJobIdRef.current ?? null);
} }
function handleExportCsv() { function handleExportCsv() {
@@ -503,8 +515,8 @@ export function CountingWizard({
setAutoAdjustedFilenames(new Set()); setAutoAdjustedFilenames(new Set());
setError(null); setError(null);
setProgress({ processed: 0, total: 0, failed: 0, currentBatch: "", startedAt: 0 }); setProgress({ processed: 0, total: 0, failed: 0, currentBatch: "", startedAt: 0 });
if (downloadJobIdRef.current) { if (source === "minio" && downloadJobIdRef.current) {
void indexFromDownload(downloadJobIdRef.current, downloadFiles); void handleMinioIndexed(downloadJobIdRef.current, []);
} else if (form.localRoot) { } else if (form.localRoot) {
void indexLocalFolder(form.localRoot); void indexLocalFolder(form.localRoot);
} }
@@ -529,43 +541,57 @@ export function CountingWizard({
<div className="wiz-section"> <div className="wiz-section">
<h3 className="wiz-section-title">Origen de las facturas</h3> <h3 className="wiz-section-title">Origen de las facturas</h3>
<p className="wiz-desc"> <p className="wiz-desc">
{fromDownload Elige carpeta local o una corrida MinIO del bot de descarga. El servicio contará
? "Los PDF se leen directamente desde MinIO (todas las carpetas NIT de la corrida de descarga)." cuántos servicios contiene cada documento usando OCR.
: "Selecciona la carpeta local con los PDF de facturas médicas. El servicio contará cuántos servicios contiene cada documento usando OCR."}
</p> </p>
</div>
{fromDownload ? ( {job?.id ? (
<div className="wiz-section"> <MinioDownloadSourcePicker
<label className="wiz-field-label">Corrida MinIO</label> 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">
<div className="folder-picker-display"> <div className="folder-picker-display">
{form.localRoot ? (
<>
<FolderOpen size={16} className="folder-picker-icon" /> <FolderOpen size={16} className="folder-picker-icon" />
<span className="folder-picker-path"> <span className="folder-picker-path">{form.localRoot}</span>
downloads/{downloadJobId} </>
</span> ) : (
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span>
)}
</div> </div>
<button <button type="button" className="btn btn-primary" onClick={selectFolder} disabled={indexing}>
type="button" {indexing ? <Loader2 size={14} className="spin" /> : <FolderOpen size={15} />}
className="btn btn-ghost" {indexing ? "Indexando…" : "Explorar"}
onClick={() => downloadJobId && void indexFromDownload(downloadJobId)}
disabled={indexing}
>
{indexing ? <Loader2 size={14} className="spin" /> : <RotateCcw size={14} />}
{indexing ? "Indexando…" : "Recargar"}
</button> </button>
</div> </div>
{manifest.length > 0 && !indexing && (
{manifest.length > 0 && !indexing && source === "local" && (
<div className="wiz-found-badge"> <div className="wiz-found-badge">
<FileText size={15} /> <FileText size={15} />
<span> <span>
<strong>{manifest.length}</strong> PDF(s) en{" "} <strong>{manifest.length}</strong> documento(s) encontrados y listos para contar
<strong>{new Set(manifest.map((m) => m.nit).filter(Boolean)).size || 1}</strong>{" "}
carpeta(s) planas — listos para contar
</span> </span>
</div> </div>
)} )}
{form.localRoot && manifest.length === 0 && !indexing && !error && source === "local" && (
<p className="wiz-hint">No se encontraron PDF en esta carpeta.</p>
)}
</div> </div>
}
/>
) : ( ) : (
<div className="wiz-section"> <div className="wiz-section">
<label className="wiz-field-label">Carpeta a procesar</label> <label className="wiz-field-label">Carpeta a procesar</label>
@@ -585,7 +611,6 @@ export function CountingWizard({
{indexing ? "Indexando…" : "Explorar"} {indexing ? "Indexando…" : "Explorar"}
</button> </button>
</div> </div>
{manifest.length > 0 && !indexing && ( {manifest.length > 0 && !indexing && (
<div className="wiz-found-badge"> <div className="wiz-found-badge">
<FileText size={15} /> <FileText size={15} />
@@ -594,11 +619,9 @@ export function CountingWizard({
</span> </span>
</div> </div>
)} )}
{form.localRoot && manifest.length === 0 && !indexing && !error && (
<p className="wiz-hint">No se encontraron PDF en esta carpeta.</p>
)}
</div> </div>
)} )}
</div>
<details className="wiz-advanced"> <details className="wiz-advanced">
<summary className="wiz-advanced-trigger">Opciones avanzadas</summary> <summary className="wiz-advanced-trigger">Opciones avanzadas</summary>
@@ -20,10 +20,20 @@ import {
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { useState } from "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 { SchemaFieldPicker } from "./SchemaFieldPicker";
import { SampleTreeView } from "./SampleTreeView"; import { SampleTreeView } from "./SampleTreeView";
import { ExtractionStepper, type ExtractionStep } from "./ExtractionStepper"; import { ExtractionStepper, type ExtractionStep } from "./ExtractionStepper";
import { MinioDownloadSourcePicker, type DocumentSourceMode } from "./MinioDownloadSourcePicker";
interface ExtractionWizardProps { interface ExtractionWizardProps {
step: ExtractionStep; step: ExtractionStep;
@@ -64,6 +74,20 @@ interface ExtractionWizardProps {
effectiveDocumentCount?: number; effectiveDocumentCount?: number;
/** Etiqueta descriptiva para el origen de los documentos (ej. "desde conteo") */ /** Etiqueta descriptiva para el origen de los documentos (ej. "desde conteo") */
documentSourceLabel?: string; 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 = [ const MODELS = [
@@ -83,6 +107,14 @@ interface EstimateResult {
extractor_model?: string; extractor_model?: string;
normalizer_model?: string; normalizer_model?: string;
pages_per_doc?: number; 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 { function fmtUsd(val: number | undefined): string {
@@ -130,12 +162,61 @@ export function ExtractionWizard({
onNext, onNext,
effectiveDocumentCount, effectiveDocumentCount,
documentSourceLabel, documentSourceLabel,
allowMinioSource = false,
docSource = "local",
onDocSourceChange,
selectedDownloadJobId,
onSelectedDownloadJobIdChange,
onMinioIndexed,
minioIndexing = false,
client,
parentJobId,
nitPages,
selectNitIndex = 0,
onSelectNitIndexChange,
}: ExtractionWizardProps) { }: ExtractionWizardProps) {
const [advancedOpen, setAdvancedOpen] = useState(false); const [advancedOpen, setAdvancedOpen] = useState(false);
const fieldCount = Object.keys(flattened.schema).length; const fieldCount = Object.keys(flattened.schema).length;
// Número efectivo de documentos: usa el override si está disponible (ej. "desde conteo") // Número efectivo de documentos: usa el override si está disponible (ej. "desde conteo")
const displayDocCount = effectiveDocumentCount ?? manifest.length; const displayDocCount = effectiveDocumentCount ?? manifest.length;
const ingestPercent = ingestProgress ? Math.round((ingestProgress.processed / ingestProgress.total) * 100) : 0; 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 ( return (
<div className="wiz"> <div className="wiz">
@@ -148,7 +229,7 @@ export function ExtractionWizard({
<div className="wiz-section"> <div className="wiz-section">
<h3 className="wiz-section-title">Origen de los documentos</h3> <h3 className="wiz-section-title">Origen de los documentos</h3>
<p className="wiz-desc"> <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> </p>
<div className="mode-switch"> <div className="mode-switch">
@@ -156,7 +237,7 @@ export function ExtractionWizard({
type="button" type="button"
className={form.mode === "radicados" ? "active" : ""} className={form.mode === "radicados" ? "active" : ""}
onClick={() => onModeChange("radicados")} onClick={() => onModeChange("radicados")}
disabled={busy} disabled={busy || docSource === "minio"}
> >
<Layers size={15} /> <Layers size={15} />
Por radicados Por radicados
@@ -171,37 +252,32 @@ export function ExtractionWizard({
Carpeta general Carpeta general
</button> </button>
</div> </div>
{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> </div>
<div className="wiz-section"> {allowMinioSource && client && parentJobId && onDocSourceChange && onMinioIndexed ? (
<label className="wiz-field-label">Carpeta a procesar</label> <MinioDownloadSourcePicker
<div className="folder-picker"> client={client}
<div className="folder-picker-display"> parentJobId={parentJobId}
{form.selectedLocalPath ? ( source={docSource}
<> onSourceChange={onDocSourceChange}
<FolderOpen size={16} className="folder-picker-icon" /> selectedDownloadJobId={selectedDownloadJobId}
<span className="folder-picker-path">{form.selectedLocalPath}</span> onSelectedDownloadJobIdChange={onSelectedDownloadJobIdChange}
</> onIndexed={onMinioIndexed}
indexing={minioIndexing || busy}
indexedFileCount={manifest.length}
indexedFolderCount={countFlatFolders(manifest)}
disabled={busy}
localContent={localFolderSection}
/>
) : ( ) : (
<span className="folder-picker-placeholder">Ninguna carpeta seleccionada</span> localFolderSection
)} )}
</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>
)}
</div>
{form.mode === "radicados" && ( {form.mode === "radicados" && (
<div className="wiz-section"> <div className="wiz-section">
@@ -422,10 +498,43 @@ export function ExtractionWizard({
{step === 3 && ( {step === 3 && (
<div className="wiz-step wiz-step-split"> <div className="wiz-step wiz-step-split">
<div className="wiz-split-main"> <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"> <div className="wiz-section-header">
<h3 className="wiz-section-title"> <h3 className="wiz-section-title">
Campos a extraer 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> </h3>
<div className="wiz-toolbar"> <div className="wiz-toolbar">
<div className="wiz-search-box"> <div className="wiz-search-box">
@@ -468,7 +577,9 @@ export function ExtractionWizard({
<div className="wiz-rediscover-text"> <div className="wiz-rediscover-text">
<strong>¿Algo no salió como esperabas?</strong> <strong>¿Algo no salió como esperabas?</strong>
<span> <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 && ( {discoverySampleLabels.length > 0 && (
<> (ahora: <em>{discoverySampleLabels.join(", ")}</em>)</> <> (ahora: <em>{discoverySampleLabels.join(", ")}</em>)</>
)} )}
@@ -515,7 +626,9 @@ export function ExtractionWizard({
</div> </div>
<h3 className="wiz-hero-title">Listo para extraer</h3> <h3 className="wiz-hero-title">Listo para extraer</h3>
<p className="wiz-desc"> <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> </p>
</div> </div>
@@ -523,9 +636,24 @@ export function ExtractionWizard({
<div className="wiz-review-item"> <div className="wiz-review-item">
<FolderOpen size={18} /> <FolderOpen size={18} />
<div> <div>
<span className="wiz-review-label">Carpeta</span> <span className="wiz-review-label">
<span className="wiz-review-value" title={form.selectedLocalPath}> {docSource === "minio" ? "Origen MinIO" : "Carpeta"}
{form.selectedLocalPath || "—"} </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> </span>
</div> </div>
</div> </div>
@@ -543,26 +671,57 @@ export function ExtractionWizard({
<div className="wiz-review-item"> <div className="wiz-review-item">
<Database size={18} /> <Database size={18} />
<div> <div>
<span className="wiz-review-label">Campos seleccionados</span> <span className="wiz-review-label">
<span className="wiz-review-value">{selection.fields.length}</span> {multiNitSelect ? "Campos (suma NIT)" : "Campos seleccionados"}
</span>
<span className="wiz-review-value">{totalSelectedFields}</span>
</div> </div>
</div> </div>
<div className="wiz-review-item"> <div className="wiz-review-item">
<Layers size={18} /> <Layers size={18} />
<div> <div>
<span className="wiz-review-label">Modo</span> <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> </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"> <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 {busy ? <Loader2 className="spin" size={15} /> : <Database size={15} />} Estimar costo
</button> </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} />} {busy ? <Loader2 className="spin" size={18} /> : <Play size={18} />}
Ejecutar extraccion {multiNitSelect ? "Ejecutar por NIT" : "Ejecutar extraccion"}
</button> </button>
</div> </div>
@@ -581,14 +740,43 @@ export function ExtractionWizard({
const normPrompt = normUsage?.prompt_tokens ?? 0; const normPrompt = normUsage?.prompt_tokens ?? 0;
const normCompl = normUsage?.completion_tokens ?? 0; const normCompl = normUsage?.completion_tokens ?? 0;
const pages = est.pages_per_doc ?? 1; const pages = est.pages_per_doc ?? 1;
const multiBreakdown = est.multi_nit_breakdown;
const isMultiEst = est.estimate_mode === "multi_nit" && Boolean(multiBreakdown?.length);
return ( return (
<div className="est-card"> <div className="est-card">
<div className="est-card-header"> <div className="est-card-header">
<Database size={15} /> <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> </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"> <div className="est-phases">
{/* Extractor */} {/* Extractor */}
<div className="est-phase"> <div className="est-phase">
@@ -600,7 +788,9 @@ export function ExtractionWizard({
<span className="est-token-row"> <span className="est-token-row">
<span className="est-token-label">Entrada</span> <span className="est-token-label">Entrada</span>
<span className="est-token-val">{fmtTokens(extPrompt)} tok</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> </span>
{extCached > 0 && ( {extCached > 0 && (
<span className="est-token-row"> <span className="est-token-row">
@@ -641,15 +831,22 @@ export function ExtractionWizard({
<div className="est-totals"> <div className="est-totals">
<div className="est-total-row"> <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> <span className="est-total-val">{fmtUsd(perDoc?.total_usd)}</span>
</div> </div>
<div className="est-total-row est-total-row--big"> <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> <span className="est-total-val est-total-grand">{fmtUsd(total?.total_usd)}</span>
</div> </div>
<p className="est-disclaimer"> <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> </p>
</div> </div>
</div> </div>
@@ -704,7 +901,9 @@ export function ExtractionWizard({
</div> </div>
{step < 4 ? ( {step < 4 ? (
<button type="button" className="btn btn-primary" onClick={onNext} disabled={busy || !canGoNext}> <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>
) : ( ) : (
<button type="button" className="btn btn-primary" onClick={runExtraction} disabled={busy || selection.fields.length === 0}> <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} downloadFiles={downloadFiles}
onTaskCreated={onTaskCreated} onTaskCreated={onTaskCreated}
onProgress={onProgress} onProgress={onProgress}
onStartExtraction={(countingTaskId, validCount, validCounts) => { onStartExtraction={(countingTaskId, validCount, validCounts, downloadJobIdFromWizard) => {
onActivateModule?.("extraction", { onActivateModule?.("extraction", {
countingTaskId, countingTaskId,
validCount, validCount,
validCounts, validCounts,
jobId: job.id, jobId: job.id,
downloadJobId, downloadJobId: downloadJobIdFromWizard ?? downloadJobId,
}); });
}} }}
/> />
@@ -3,43 +3,102 @@ import { useEffect, useState } from "react";
import type { ExtractionBatch } from "../../../shared/types"; import type { ExtractionBatch } from "../../../shared/types";
import { ExtractionResultsTable } from "../../components/ExtractionResultsTable"; import { ExtractionResultsTable } from "../../components/ExtractionResultsTable";
import { downloadCsv, toCsv, buildExportCsvFilename } from "../../lib/csv"; import { downloadCsv, toCsv, buildExportCsvFilename } from "../../lib/csv";
import { mergeExtractionRows, type ExtractionRow } from "../../lib/mergeExtractionRows";
import type { ModuleResultsProps } from "../registry"; 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 [batch, setBatch] = useState<ExtractionBatch | null>(null);
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<ExtractionRow[]>([]);
const [columns, setColumns] = useState<string[]>([]); const [columns, setColumns] = useState<string[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [polling, setPolling] = useState<ReturnType<typeof setInterval> | null>(null); const [polling, setPolling] = useState<ReturnType<typeof setInterval> | null>(null);
const [doneCount, setDoneCount] = useState(0);
function stop() { function stop() {
if (polling !== null) { clearInterval(polling); setPolling(null); } if (polling !== null) {
clearInterval(polling);
setPolling(null);
}
} }
async function load() { async function load() {
try { 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 = { const pseudoBatch: ExtractionBatch = {
id: String((t.result as Record<string, unknown>)?.extraction_batch_id ?? t.id), id: String((primary.result as Record<string, unknown>)?.extraction_batch_id ?? primary.id),
status: t.status as ExtractionBatch["status"], status: anyRunning ? "running" : (primary.status as ExtractionBatch["status"]),
root_path: "", root_path: "",
request: {}, request: {},
created_at: t.created_at, created_at: primary.created_at,
updated_at: t.updated_at, updated_at: primary.updated_at,
}; };
setBatch(pseudoBatch); setBatch(pseudoBatch);
if (t.status === "running" || t.status === "pending") return;
if (!allDone) return;
stop(); stop();
const response = await client.taskRows(t.id, 500, 0);
const data = response.rows ?? []; const rowSets: ExtractionRow[][] = [];
setRows(data); for (const t of tasks) {
const cols = Array.from(new Set(data.flatMap((r: Record<string, unknown>) => Object.keys(r)))).filter( const response = await client.taskRows(t.id, 2000, 0);
(c) => !["_id", "batch_id"].includes(c), const data = (response.rows ?? []) as ExtractionRow[];
) as string[]; // Añadir nit desde request de la task si falta en la fila
setColumns(cols); 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) { } catch (e) {
setError(String(e)); setError(String(e));
stop(); stop();
@@ -53,7 +112,8 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul
const id = setInterval(load, 5000); const id = setInterval(load, 5000);
setPolling(id); setPolling(id);
return () => clearInterval(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() { function exportCsv() {
const csv = toCsv( const csv = toCsv(
@@ -65,9 +125,7 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul
function startCleaning() { function startCleaning() {
const batchId = String( const batchId = String(
(task.result as Record<string, unknown>)?.extraction_batch_id (task.result as Record<string, unknown>)?.extraction_batch_id ?? batch?.id ?? "",
?? batch?.id
?? "",
); );
onActivateModule?.("cleaning", { onActivateModule?.("cleaning", {
extractionBatchId: batchId || undefined, extractionBatchId: batchId || undefined,
@@ -89,7 +147,13 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul
return ( return (
<div className="wiz-toast wiz-toast--error" style={{ margin: 16 }}> <div className="wiz-toast wiz-toast--error" style={{ margin: 16 }}>
{error} {error}
<button className="btn btn-sm" onClick={() => { setError(null); load(); }}> <button
className="btn btn-sm"
onClick={() => {
setError(null);
void load();
}}
>
<RefreshCw size={13} /> Reintentar <RefreshCw size={13} /> Reintentar
</button> </button>
</div> </div>
@@ -102,12 +166,11 @@ export function ExtractionResults({ client, task, job, onActivateModule }: Modul
<div className="module-results"> <div className="module-results">
<div className="module-results-header"> <div className="module-results-header">
<div className="module-results-title"> <div className="module-results-title">
<h3>{task.title}</h3> <h3>{multi ? `${job.title || "Extracción"} · ${taskIds.length} NIT` : task.title}</h3>
<span <span className={`task-badge task-badge--${batch?.status ?? "pending"}`}>
className={`task-badge task-badge--${batch?.status ?? "pending"}`}
>
{isRunning && <Loader2 size={11} className="spin" />} {isRunning && <Loader2 size={11} className="spin" />}
{batch?.status ?? "pendiente"} {batch?.status ?? "pendiente"}
{multi && isRunning ? ` · ${doneCount}/${taskIds.length}` : ""}
</span> </span>
</div> </div>
<div style={{ display: "flex", gap: 8 }}> <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) <Download size={13} /> Descargar CSV ({rows.length} filas)
</button> </button>
)} )}
{rows.length > 0 && !isRunning && onActivateModule && ( {rows.length > 0 && !isRunning && onActivateModule && !multi && (
<button type="button" className="btn btn-sm btn-primary" onClick={startCleaning}> <button type="button" className="btn btn-sm btn-primary" onClick={startCleaning}>
<Sparkles size={13} /> Limpiar datos <ArrowRight size={13} /> <Sparkles size={13} /> Limpiar datos <ArrowRight size={13} />
</button> </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>
</div> </div>
{isRunning && ( {isRunning && (
<div className="wiz-progress" style={{ margin: "0 16px 12px" }}> <div className="wiz-progress" style={{ margin: "0 16px 12px" }}>
<div className="wiz-progress-header"> <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" /> <Loader2 size={13} className="spin" />
</div> </div>
<div className="wiz-progress-track"> <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>
</div> </div>
)} )}
@@ -2,13 +2,17 @@
* Wrapper del ExtractionWizard original para el contexto de Job. * Wrapper del ExtractionWizard original para el contexto de Job.
* Contiene toda la lógica de estado que antes vivía en App.tsx, * Contiene toda la lógica de estado que antes vivía en App.tsx,
* preservando el diseño paso a paso completo. * preservando el diseño paso a paso completo.
*
* Multi-NIT (MinIO): discovery y selección por carpeta plana; extracción
* secuencial por NIT; un solo CSV fusionado al final.
*/ */
import { ArrowRight, CheckCircle2, FileText } from "lucide-react"; import { ArrowRight, CheckCircle2, Cloud, FileText } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import type { import type {
BackgroundProgress, BackgroundProgress,
CountJobFile, CountJobFile,
DocumentType, DocumentType,
DownloadJobFile,
ExtractionFormState, ExtractionFormState,
FieldSelection, FieldSelection,
ManifestEntry, ManifestEntry,
@@ -17,6 +21,7 @@ import type {
} from "../../../shared/types"; } from "../../../shared/types";
import { ExtractionWizard } from "../../components/ExtractionWizard"; import { ExtractionWizard } from "../../components/ExtractionWizard";
import type { ExtractionStep } from "../../components/ExtractionStepper"; import type { ExtractionStep } from "../../components/ExtractionStepper";
import type { DocumentSourceMode } from "../../components/MinioDownloadSourcePicker";
import { import {
buildDesktopBatchPayload, buildDesktopBatchPayload,
buildEstimateUploadForm, buildEstimateUploadForm,
@@ -26,6 +31,7 @@ import {
mergeSchemaResponses, mergeSchemaResponses,
validDocTypes, validDocTypes,
} from "../../lib/payloads"; } from "../../lib/payloads";
import { countFlatFolders, downloadFilesToManifest } from "../../lib/downloadManifest";
import { defaultAlias } from "../../lib/schemaDisplay"; import { defaultAlias } from "../../lib/schemaDisplay";
import { loadApiBaseUrl } from "../../lib/apiDefaults"; import { loadApiBaseUrl } from "../../lib/apiDefaults";
import { splitRadicados } from "../../lib/pathMapping"; import { splitRadicados } from "../../lib/pathMapping";
@@ -35,16 +41,18 @@ import { ExtractionResults } from "./ExtractionResults";
declare const desktopApi: import("../../../shared/types").DesktopBridge; declare const desktopApi: import("../../../shared/types").DesktopBridge;
interface Props extends ModuleWizardProps { interface Props extends ModuleWizardProps {
/** ID de tarea de conteo previa (desde contexto de JobView) */
countingTaskId?: string; countingTaskId?: string;
/** Conteos exactos válidos para filtrar desde el conteo */
validCounts?: number[]; validCounts?: number[];
/** Número de archivos válidos del conteo (para mostrar en banner) */
validCount?: number; validCount?: number;
/** Callback de progreso en segundo plano, para mostrarlo desde otras secciones. */
onProgress?: (progress: BackgroundProgress) => void; onProgress?: (progress: BackgroundProgress) => void;
} }
export type NitSchemaPage = {
nit: string;
schema: SchemaResponse;
sampleLabel: string;
};
function makeDefaultForm(job: { metadata?: Record<string, unknown> }, fromCount = false): ExtractionFormState { function makeDefaultForm(job: { metadata?: Record<string, unknown> }, fromCount = false): ExtractionFormState {
const folder = String(job.metadata?.folder_path ?? ""); const folder = String(job.metadata?.folder_path ?? "");
return { return {
@@ -53,8 +61,6 @@ function makeDefaultForm(job: { metadata?: Record<string, unknown> }, fromCount
containerRoot: localStorage.getItem("containerRoot") || "/data", containerRoot: localStorage.getItem("containerRoot") || "/data",
selectedLocalPath: folder, selectedLocalPath: folder,
selectedContainerPath: 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", mode: fromCount ? "general" : "radicados",
docTypes: [{ name: "Factura", keyword: "Factura_O_Documento_Equivalente" }], docTypes: [{ name: "Factura", keyword: "Factura_O_Documento_Equivalente" }],
keywordsText: "factura", keywordsText: "factura",
@@ -68,6 +74,20 @@ function makeDefaultForm(job: { metadata?: Record<string, unknown> }, fromCount
}; };
} }
function emptySelectionForSchema(schema: SchemaResponse): FieldSelection {
const flat = flattenSchema(schema);
return {
fields: [],
aliasMap: Object.fromEntries(Object.keys(flat.schema).map((p) => [p, defaultAlias(p)])),
};
}
function listManifestNits(manifest: ManifestEntry[]): string[] {
return [
...new Set(manifest.map((m) => (m.nit || "").trim()).filter(Boolean)),
].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
}
export function JobExtractionWizard({ export function JobExtractionWizard({
client, client,
job, job,
@@ -82,12 +102,13 @@ export function JobExtractionWizard({
const countingTaskId = countingTaskIdProp ?? (context?.countingTaskId as string | undefined); const countingTaskId = countingTaskIdProp ?? (context?.countingTaskId as string | undefined);
const validCounts = validCountsProp ?? (context?.validCounts as number[] | undefined); const validCounts = validCountsProp ?? (context?.validCounts as number[] | undefined);
const validCount = validCountProp ?? (context?.validCount as number | undefined); const validCount = validCountProp ?? (context?.validCount as number | undefined);
const downloadJobId = const downloadJobId = (context?.downloadJobId as string | undefined) ?? undefined;
(context?.downloadJobId as string | undefined) ??
undefined;
const [form, setForm] = useState<ExtractionFormState>(() => makeDefaultForm(job, !!countingTaskId)); const [form, setForm] = useState<ExtractionFormState>(() => makeDefaultForm(job, !!countingTaskId));
const [schemaResponse, setSchemaResponse] = useState<SchemaResponse | null>(null); const [schemaResponse, setSchemaResponse] = useState<SchemaResponse | null>(null);
const [nitSchemas, setNitSchemas] = useState<NitSchemaPage[]>([]);
const [selectionsByNit, setSelectionsByNit] = useState<Record<string, FieldSelection>>({});
const [selectNitIndex, setSelectNitIndex] = useState(0);
const [schemaSearch, setSchemaSearch] = useState(""); const [schemaSearch, setSchemaSearch] = useState("");
const [selection, setSelection] = useState<FieldSelection>({ fields: [], aliasMap: {} }); const [selection, setSelection] = useState<FieldSelection>({ fields: [], aliasMap: {} });
const [estimateJson, setEstimateJson] = useState<unknown>(null); const [estimateJson, setEstimateJson] = useState<unknown>(null);
@@ -96,30 +117,63 @@ export function JobExtractionWizard({
const [statusText, setStatusText] = useState("Listo"); const [statusText, setStatusText] = useState("Listo");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Si viene del conteo, empezamos en paso 2 (los archivos ya están definidos) const [extractionStep, setExtractionStep] = useState<ExtractionStep>(() => (countingTaskId ? 2 : 1));
const [extractionStep, setExtractionStep] = useState<ExtractionStep>(() => countingTaskId ? 2 : 1); const [maxReachedStep, setMaxReachedStep] = useState<ExtractionStep>(() => (countingTaskId ? 2 : 1));
const [maxReachedStep, setMaxReachedStep] = useState<ExtractionStep>(() => countingTaskId ? 2 : 1);
const [taskCreated, setTaskCreated] = useState<Task | null>(null); const [taskCreated, setTaskCreated] = useState<Task | null>(null);
/** Rutas de muestras ya usadas en descubrimiento (para no repetir al reintentar). */ const [childTaskIds, setChildTaskIds] = useState<string[]>([]);
const [usedDiscoveryPaths, setUsedDiscoveryPaths] = useState<string[]>([]); const [usedDiscoveryPaths, setUsedDiscoveryPaths] = useState<string[]>([]);
const [discoverySampleLabels, setDiscoverySampleLabels] = useState<string[]>([]); const [discoverySampleLabels, setDiscoverySampleLabels] = useState<string[]>([]);
const [resolvedDownloadJobId, setResolvedDownloadJobId] = useState<string | undefined>(downloadJobId); const [resolvedDownloadJobId, setResolvedDownloadJobId] = useState<string | undefined>(downloadJobId);
const [docSource, setDocSource] = useState<DocumentSourceMode>(
downloadJobId && !countingTaskId ? "minio" : "local",
);
const [minioIndexing, setMinioIndexing] = useState(false);
const multiNit = nitSchemas.length > 1;
const activeNit = multiNit ? nitSchemas[selectNitIndex]?.nit : undefined;
const activeSchema = multiNit
? nitSchemas[selectNitIndex]?.schema ?? null
: schemaResponse;
const flattened = useMemo( const flattened = useMemo(
() => (schemaResponse ? flattenSchema(schemaResponse) : { schema: {}, sample: null }), () => (activeSchema ? flattenSchema(activeSchema) : { schema: {}, sample: null }),
[schemaResponse], [activeSchema],
); );
const fieldCount = Object.keys(flattened.schema).length; const fieldCount = Object.keys(flattened.schema).length;
const effectiveSelection = multiNit && activeNit
? selectionsByNit[activeNit] ?? { fields: [], aliasMap: {} }
: selection;
const nitPages = useMemo(() => {
if (!multiNit) return undefined;
return nitSchemas.map((page) => {
const sel = selectionsByNit[page.nit] ?? { fields: [], aliasMap: {} };
const flat = flattenSchema(page.schema);
return {
nit: page.nit,
fieldCount: Object.keys(flat.schema).length,
selectedCount: sel.fields.length,
docCount: manifest.filter((m) => (m.nit || "").trim() === page.nit).length,
};
});
}, [multiNit, nitSchemas, selectionsByNit, manifest]);
const allNitsHaveSelection =
!multiNit ||
nitSchemas.every((p) => (selectionsByNit[p.nit]?.fields.length ?? 0) > 0);
const canGoNext = const canGoNext =
// Paso 1: necesita documentos en carpeta (o saltarse si viene de conteo)
(extractionStep === 1 && (manifest.length > 0 || !!countingTaskId)) || (extractionStep === 1 && (manifest.length > 0 || !!countingTaskId)) ||
// Paso 2: necesita campos descubiertos (extractionStep === 2 && (multiNit ? nitSchemas.length > 0 : fieldCount > 0)) ||
(extractionStep === 2 && fieldCount > 0) || (extractionStep === 3 &&
// Paso 3: necesita al menos un campo seleccionado (multiNit
(extractionStep === 3 && selection.fields.length > 0); ? effectiveSelection.fields.length > 0 &&
(selectNitIndex < nitSchemas.length - 1 || allNitsHaveSelection)
: selection.fields.length > 0));
// Auto-indexar: carpeta local, o archivos del conteo desde MinIO
useEffect(() => { useEffect(() => {
if (countingTaskId) { if (countingTaskId) {
void (async () => { void (async () => {
@@ -157,13 +211,13 @@ export function JobExtractionWizard({
return; return;
} }
} catch { } catch {
/* fallback a carpeta local abajo */ /* fallback */
} }
})(); })();
} }
const folder = form.localRoot || form.selectedLocalPath; const folder = form.localRoot || form.selectedLocalPath;
if (!folder || manifest.length > 0 || countingTaskId) return; if (!folder || manifest.length > 0 || countingTaskId || docSource === "minio") return;
indexFolderAt(folder, form.mode, form.docTypes) indexFolderAt(folder, form.mode, form.docTypes)
.then((entries) => { .then((entries) => {
setManifest(entries); setManifest(entries);
@@ -173,6 +227,12 @@ export function JobExtractionWizard({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [countingTaskId]); }, [countingTaskId]);
// Sync selection view when changing NIT page
useEffect(() => {
if (!multiNit || !activeNit) return;
setSchemaSearch("");
}, [multiNit, activeNit, selectNitIndex]);
async function readEntryBytes(entry: ManifestEntry): Promise<{ filename: string; data: ArrayBuffer }> { async function readEntryBytes(entry: ManifestEntry): Promise<{ filename: string; data: ArrayBuffer }> {
if (entry.objectKey && resolvedDownloadJobId) { if (entry.objectKey && resolvedDownloadJobId) {
const data = await client.fetchDownloadObject(resolvedDownloadJobId, entry.objectKey); const data = await client.fetchDownloadObject(resolvedDownloadJobId, entry.objectKey);
@@ -208,27 +268,95 @@ export function JobExtractionWizard({
function resetSchemaState() { function resetSchemaState() {
setSchemaResponse(null); setSchemaResponse(null);
setNitSchemas([]);
setSelectionsByNit({});
setSelectNitIndex(0);
setSelection({ fields: [], aliasMap: {} }); setSelection({ fields: [], aliasMap: {} });
setEstimateJson(null); setEstimateJson(null);
setUsedDiscoveryPaths([]); setUsedDiscoveryPaths([]);
setDiscoverySampleLabels([]); setDiscoverySampleLabels([]);
setChildTaskIds([]);
setMaxReachedStep(1); setMaxReachedStep(1);
if (extractionStep > 1) setExtractionStep(1); if (extractionStep > 1) setExtractionStep(1);
} }
const handleMinioIndexed = useCallback(
async (jobId: string, files: DownloadJobFile[]) => {
setMinioIndexing(true);
setError(null);
try {
const entries = downloadFilesToManifest(files);
setResolvedDownloadJobId(jobId);
setManifest(entries);
updateForm({ mode: "general" });
resetSchemaState();
const folders = countFlatFolders(entries);
setStatusText(
entries.length === 0
? "Sin PDF en la corrida MinIO"
: `${entries.length} PDF(s) desde MinIO · ${folders} carpeta(s) planas`,
);
if (entries.length === 0) {
setError("No se encontraron PDF en MinIO para esta corrida de descarga.");
}
} finally {
setMinioIndexing(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[extractionStep],
);
const handleDocSourceChange = useCallback(
(next: DocumentSourceMode) => {
setDocSource(next);
setError(null);
setManifest([]);
resetSchemaState();
if (next === "minio") {
setResolvedDownloadJobId(undefined);
updateForm({ mode: "general" });
setStatusText("Selecciona una corrida MinIO");
} else {
setResolvedDownloadJobId(undefined);
setStatusText("Listo");
const folder = form.localRoot || form.selectedLocalPath;
if (folder) {
void indexFolderAt(folder, form.mode === "radicados" ? form.mode : "general", form.docTypes)
.then((entries) => {
setManifest(entries);
setStatusText(`${entries.length} documento(s) listos`);
})
.catch(() => undefined);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[form.localRoot, form.selectedLocalPath, form.mode, form.docTypes],
);
async function selectRoot() { async function selectRoot() {
if (!window.desktopApi) { setError("La selección de carpetas requiere Electron."); return; } if (!window.desktopApi) {
setError("La selección de carpetas requiere Electron.");
return;
}
setError(null); setError(null);
const selected = await window.desktopApi.selectDirectory(); const selected = await window.desktopApi.selectDirectory();
if (!selected) return; if (!selected) return;
try { try {
const indexed = await indexFolderAt(selected, form.mode, form.docTypes); const indexed = await indexFolderAt(selected, form.mode, form.docTypes);
setManifest(indexed); setManifest(indexed);
setResolvedDownloadJobId(undefined);
setDocSource("local");
updateForm({ localRoot: selected, selectedLocalPath: selected, selectedContainerPath: selected }); updateForm({ localRoot: selected, selectedLocalPath: selected, selectedContainerPath: selected });
resetSchemaState(); resetSchemaState();
setMaxReachedStep(1); setMaxReachedStep(1);
setStatusText(`${indexed.length} documento(s) listos`); setStatusText(`${indexed.length} documento(s) listos`);
try { await client.updateJob(job.id, { folder_path: selected }); } catch { /* ignore */ } try {
await client.updateJob(job.id, { folder_path: selected });
} catch {
/* ignore */
}
} catch (err) { } catch (err) {
setManifest([]); setManifest([]);
setError(err instanceof Error ? err.message : "No se pudo indexar la carpeta"); setError(err instanceof Error ? err.message : "No se pudo indexar la carpeta");
@@ -238,6 +366,7 @@ export function JobExtractionWizard({
async function handleModeChange(mode: ExtractionFormState["mode"]) { async function handleModeChange(mode: ExtractionFormState["mode"]) {
if (mode === form.mode) return; if (mode === form.mode) return;
updateForm({ mode }); updateForm({ mode });
if (docSource === "minio") return;
if (!form.selectedLocalPath) return; if (!form.selectedLocalPath) return;
try { try {
const indexed = await indexFolderAt(form.selectedLocalPath, mode, form.docTypes); const indexed = await indexFolderAt(form.selectedLocalPath, mode, form.docTypes);
@@ -262,49 +391,109 @@ export function JobExtractionWizard({
const unused = candidates.filter((e) => !exclude.has(keyOf(e))); const unused = candidates.filter((e) => !exclude.has(keyOf(e)));
const pool = unused.length > 0 ? unused : candidates; const pool = unused.length > 0 ? unused : candidates;
if (!preferRandom && unused.length === candidates.length) { if (!preferRandom && unused.length === candidates.length) {
return candidates[0]; return candidates[0]!;
} }
return pool[Math.floor(Math.random() * pool.length)]!; return pool[Math.floor(Math.random() * pool.length)]!;
} }
async function discoverSchema(options?: { randomOther?: boolean }) { async function discoverOneNit(
nit: string,
exclude: Set<string>,
randomOther: boolean,
): Promise<{ schema: SchemaResponse; entry: ManifestEntry }> {
const pool = manifest.filter((m) => (m.nit || "").trim() === nit);
const entry = pickSampleEntry(pool, exclude, randomOther);
const fileData = await readEntryBytes(entry);
const blob = new Blob([fileData.data]);
const schema = await client.discoverSchemaUpload(
buildSchemaUploadForm(form, blob, fileData.filename),
);
return { schema, entry };
}
async function discoverSchema(options?: { randomOther?: boolean; onlyActiveNit?: boolean }) {
const randomOther = Boolean(options?.randomOther); const randomOther = Boolean(options?.randomOther);
const onlyActiveNit = Boolean(options?.onlyActiveNit);
await runTask( await runTask(
randomOther ? "Probando con otro archivo…" : "Descubriendo campos...", randomOther ? "Probando con otro archivo…" : "Descubriendo campos...",
async () => { async () => {
if (manifest.length === 0) throw new Error("Selecciona una carpeta con documentos."); if (manifest.length === 0) throw new Error("Selecciona una carpeta con documentos.");
const exclude = new Set(randomOther ? usedDiscoveryPaths : []); const exclude = new Set(randomOther ? usedDiscoveryPaths : []);
const fromMinio = manifest.some((m) => m.objectKey);
const nits = listManifestNits(manifest);
// Rediscover solo el NIT activo (paso 3 multi-NIT)
if (onlyActiveNit && multiNit && activeNit) {
const { schema, entry } = await discoverOneNit(activeNit, exclude, true);
const label = `${activeNit}/${entry.filename}`;
setNitSchemas((prev) =>
prev.map((p) =>
p.nit === activeNit ? { nit: activeNit, schema, sampleLabel: label } : p,
),
);
setSelectionsByNit((prev) => ({
...prev,
[activeNit]: emptySelectionForSchema(schema),
}));
setDiscoverySampleLabels([label]);
setUsedDiscoveryPaths((prev) => [...prev, entry.objectKey || entry.localPath]);
setStatusText(
`${Object.keys(flattenSchema(schema).schema).length} campo(s) · NIT ${activeNit} · muestra: ${label}`,
);
return;
}
// Multi-NIT: un schema por carpeta (sin merge para el picker)
if (fromMinio && form.mode === "general" && nits.length > 1) {
const pages: NitSchemaPage[] = [];
const selMap: Record<string, FieldSelection> = {};
const labels: string[] = [];
const used: string[] = [];
for (let i = 0; i < nits.length; i++) {
const nit = nits[i]!;
setStatusText(`Descubriendo carpeta ${i + 1}/${nits.length} (NIT ${nit})…`);
const { schema, entry } = await discoverOneNit(nit, exclude, randomOther);
const label = `${nit}/${entry.filename}`;
pages.push({ nit, schema, sampleLabel: label });
selMap[nit] = emptySelectionForSchema(schema);
labels.push(label);
used.push(entry.objectKey || entry.localPath);
exclude.add(entry.objectKey || entry.localPath);
}
setNitSchemas(pages);
setSelectionsByNit(selMap);
setSelectNitIndex(0);
setSchemaResponse(pages[0]?.schema ?? null);
setSelection(selMap[pages[0]!.nit]!);
setEstimateJson(null);
setDiscoverySampleLabels(labels);
setUsedDiscoveryPaths(randomOther ? [...usedDiscoveryPaths, ...used] : used);
setStatusText(
`${pages.length} schemas descubiertos (uno por NIT) · selección por carpeta`,
);
setMaxReachedStep((cur) => (cur < 3 ? 3 : cur));
if (!randomOther) setExtractionStep(3);
return;
}
// Flujo simple (1 NIT / local / radicados)
const responses: SchemaResponse[] = []; const responses: SchemaResponse[] = [];
const picked: ManifestEntry[] = []; const picked: ManifestEntry[] = [];
const fromMinio = manifest.some((m) => m.objectKey); if (fromMinio && form.mode === "general" && nits.length === 1) {
const nits = [ const { schema, entry } = await discoverOneNit(nits[0]!, exclude, randomOther);
...new Set(manifest.map((m) => (m.nit || "").trim()).filter(Boolean)), responses.push(schema);
].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
// Desde descarga multi-NIT: 1 muestra por carpeta plana, luego merge.
if (fromMinio && form.mode === "general" && nits.length > 0) {
for (const nit of nits) {
const pool = manifest.filter((m) => (m.nit || "").trim() === nit);
const entry = pickSampleEntry(pool, exclude, randomOther);
picked.push(entry); picked.push(entry);
const fileData = await readEntryBytes(entry);
const blob = new Blob([fileData.data]);
responses.push(
await client.discoverSchemaUpload(
buildSchemaUploadForm(form, blob, fileData.filename),
),
);
}
} else if (form.mode === "general") { } else if (form.mode === "general") {
const entry = pickSampleEntry(manifest, exclude, randomOther); const entry = pickSampleEntry(manifest, exclude, randomOther);
picked.push(entry); picked.push(entry);
const fileData = await readEntryBytes(entry); const fileData = await readEntryBytes(entry);
const blob = new Blob([fileData.data]);
responses.push( responses.push(
await client.discoverSchemaUpload( await client.discoverSchemaUpload(
buildSchemaUploadForm(form, blob, fileData.filename), buildSchemaUploadForm(form, new Blob([fileData.data]), fileData.filename),
), ),
); );
} else { } else {
@@ -316,16 +505,18 @@ export function JobExtractionWizard({
const entry = pickSampleEntry(typed, exclude, randomOther); const entry = pickSampleEntry(typed, exclude, randomOther);
picked.push(entry); picked.push(entry);
const fileData = await readEntryBytes(entry); const fileData = await readEntryBytes(entry);
const blob = new Blob([fileData.data]);
responses.push( responses.push(
await client.discoverSchemaUpload( await client.discoverSchemaUpload(
buildSchemaUploadForm(form, blob, fileData.filename, docType.name), buildSchemaUploadForm(form, new Blob([fileData.data]), fileData.filename, docType.name),
), ),
); );
} }
} }
const schema = mergeSchemaResponses(responses); const schema = mergeSchemaResponses(responses);
setNitSchemas([]);
setSelectionsByNit({});
setSelectNitIndex(0);
setSchemaResponse(schema); setSchemaResponse(schema);
const flat = flattenSchema(schema); const flat = flattenSchema(schema);
setSelection({ setSelection({
@@ -333,9 +524,7 @@ export function JobExtractionWizard({
aliasMap: Object.fromEntries(Object.keys(flat.schema).map((p) => [p, defaultAlias(p)])), aliasMap: Object.fromEntries(Object.keys(flat.schema).map((p) => [p, defaultAlias(p)])),
}); });
setEstimateJson(null); setEstimateJson(null);
const labels = picked.map((e) => const labels = picked.map((e) => (e.nit ? `${e.nit}/${e.filename}` : e.filename));
e.nit ? `${e.nit}/${e.filename}` : e.filename,
);
setDiscoverySampleLabels(labels); setDiscoverySampleLabels(labels);
setUsedDiscoveryPaths((prev) => { setUsedDiscoveryPaths((prev) => {
const next = new Set(randomOther ? prev : []); const next = new Set(randomOther ? prev : []);
@@ -347,33 +536,126 @@ export function JobExtractionWizard({
(labels.length ? ` · muestra: ${labels.join(", ")}` : ""), (labels.length ? ` · muestra: ${labels.join(", ")}` : ""),
); );
setMaxReachedStep((cur) => (cur < 3 ? 3 : cur)); setMaxReachedStep((cur) => (cur < 3 ? 3 : cur));
if (!randomOther) { if (!randomOther) setExtractionStep(3);
setExtractionStep(3);
}
}, },
); );
} }
async function rediscoverWithOtherSample() { async function rediscoverWithOtherSample() {
await discoverSchema({ randomOther: true }); await discoverSchema({ randomOther: true, onlyActiveNit: multiNit && extractionStep === 3 });
} }
async function estimate() { async function estimate() {
await runTask("Estimando costo...", async () => { await runTask("Estimando costo...", async () => {
if (!selection.fields.length) throw new Error("Selecciona al menos un campo.");
if (manifest.length === 0) throw new Error("Selecciona una carpeta."); if (manifest.length === 0) throw new Error("Selecciona una carpeta.");
const sampleEntry = manifest[0];
// Multi-NIT: un estimado por carpeta (sus campos × sus PDF) y suma.
if (multiNit) {
type Part = {
nit: string;
docs: number;
fields: number;
total_usd: number;
per_doc_usd: number;
};
const parts: Part[] = [];
let totalDocs = 0;
let totalUsd = 0;
let totalInputUsd = 0;
let totalOutputUsd = 0;
let sampleResult: Record<string, unknown> | null = null;
for (let i = 0; i < nitSchemas.length; i++) {
const page = nitSchemas[i]!;
const sel = selectionsByNit[page.nit];
if (!sel || sel.fields.length === 0) {
throw new Error(`Selecciona al menos un campo para NIT ${page.nit}.`);
}
const pool = manifest.filter((m) => (m.nit || "").trim() === page.nit);
if (pool.length === 0) continue;
setStatusText(`Estimando NIT ${i + 1}/${nitSchemas.length} (${page.nit})…`);
const sampleEntry = pool[0]!;
const fileData = await readEntryBytes(sampleEntry);
const formData = buildEstimateUploadForm(
form,
new Blob([fileData.data]),
fileData.filename,
pool.length,
sel.fields,
form.model,
);
const result = (await client.estimateUpload(formData)) as Record<string, unknown>;
if (!sampleResult) sampleResult = result;
const docs = Number(result.total_docs ?? pool.length) || pool.length;
const estTotal = (result.estimated_total_cost || {}) as {
total_usd?: number;
input_usd?: number;
output_usd?: number;
};
const perDoc = (result.per_doc_cost || {}) as { total_usd?: number };
const partTotal = Number(estTotal.total_usd ?? 0);
const partPerDoc =
Number(perDoc.total_usd ?? (docs > 0 ? partTotal / docs : 0)) || 0;
parts.push({
nit: page.nit,
docs,
fields: sel.fields.length,
total_usd: partTotal,
per_doc_usd: partPerDoc,
});
totalDocs += docs;
totalUsd += partTotal;
totalInputUsd += Number(estTotal.input_usd ?? 0);
totalOutputUsd += Number(estTotal.output_usd ?? 0);
}
if (parts.length === 0 || !sampleResult) {
throw new Error("No se pudo estimar ningún NIT.");
}
setEstimateJson({
...sampleResult,
total_docs: totalDocs,
per_doc_cost: {
...((sampleResult.per_doc_cost as object) || {}),
total_usd: totalDocs > 0 ? totalUsd / totalDocs : 0,
},
estimated_total_cost: {
input_usd: totalInputUsd,
output_usd: totalOutputUsd,
total_usd: totalUsd,
},
multi_nit_breakdown: parts,
estimate_mode: "multi_nit",
});
setStatusText(
`Estimación multi-NIT: ${totalDocs} docs · $${totalUsd.toFixed(4)}`,
);
return;
}
if (selection.fields.length === 0) throw new Error("Selecciona al menos un campo.");
const sampleEntry = manifest[0]!;
const fileData = await readEntryBytes(sampleEntry); const fileData = await readEntryBytes(sampleEntry);
const blob = new Blob([fileData.data]); const blob = new Blob([fileData.data]);
const uniqueRadicados = new Set(manifest.map((e) => e.radicado)).size; const uniqueRadicados = new Set(manifest.map((e) => e.radicado)).size;
const formData = buildEstimateUploadForm(form, blob, fileData.filename, uniqueRadicados, selection.fields, form.model); const formData = buildEstimateUploadForm(
form,
blob,
fileData.filename,
uniqueRadicados,
selection.fields,
form.model,
);
const result = await client.estimateUpload(formData); const result = await client.estimateUpload(formData);
setEstimateJson(result); setEstimateJson(result);
setStatusText("Estimación completada"); setStatusText("Estimación completada");
}); });
} }
/** Determina si un archivo del conteo es válido según los filtros activos */
function isCountFileValid(f: CountJobFile): boolean { function isCountFileValid(f: CountJobFile): boolean {
if (f.status !== "ok" || f.is_valid === false) return false; if (f.status !== "ok" || f.is_valid === false) return false;
if (f.is_valid === true) return true; if (f.is_valid === true) return true;
@@ -381,14 +663,7 @@ export function JobExtractionWizard({
return validCounts.includes(f.service_count ?? 0); return validCounts.includes(f.service_count ?? 0);
} }
async function runExtraction() { async function resolveBaseManifest(): Promise<ManifestEntry[]> {
await runTask("Iniciando extracción...", async () => {
if (selection.fields.length === 0) throw new Error("Selecciona al menos un campo.");
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) { if (countingTaskId) {
const countFiles = await client.countJobFiles(countingTaskId); const countFiles = await client.countJobFiles(countingTaskId);
const validKeys = new Set( const validKeys = new Set(
@@ -396,10 +671,8 @@ export function JobExtractionWizard({
.filter(isCountFileValid) .filter(isCountFileValid)
.map((f) => f.object_key || `${f.nit ?? ""}|${f.filename}`), .map((f) => f.object_key || `${f.nit ?? ""}|${f.filename}`),
); );
const validFilenames = new Set( const validFilenames = new Set(countFiles.filter(isCountFileValid).map((f) => f.filename));
countFiles.filter(isCountFileValid).map((f) => f.filename), const workManifest = manifest.filter((e) => {
);
workManifest = manifest.filter((e) => {
if (e.objectKey) return validKeys.has(e.objectKey); if (e.objectKey) return validKeys.has(e.objectKey);
if (e.nit) return validKeys.has(`${e.nit}|${e.filename}`); if (e.nit) return validKeys.has(`${e.nit}|${e.filename}`);
return validFilenames.has(e.filename); return validFilenames.has(e.filename);
@@ -412,23 +685,22 @@ export function JobExtractionWizard({
: " Asegúrate de que los PDFs válidos estén en la carpeta seleccionada."), : " Asegúrate de que los PDFs válidos estén en la carpeta seleccionada."),
); );
} }
} else { return workManifest;
// ── Caso 2: extracción desktop normal ── }
workManifest = [...manifest]; let workManifest = [...manifest];
if (form.filterEnabled) { if (form.filterEnabled) {
const allowed = new Set(splitRadicados(form.radicadoFilterText)); const allowed = new Set(splitRadicados(form.radicadoFilterText));
workManifest = workManifest.filter((e) => allowed.has(e.radicado)); workManifest = workManifest.filter((e) => allowed.has(e.radicado));
} }
if (workManifest.length === 0) throw new Error("El filtro no dejó documentos."); if (workManifest.length === 0) throw new Error("El filtro no dejó documentos.");
return workManifest;
} }
const payload = buildDesktopBatchPayload(form, selection, workManifest, form.model); async function ingestManifestToTask(
const payloadWithJob = { ...payload, job_id: job.id }; task: Task,
const task = await client.createExtractionTask( workManifest: ManifestEntry[],
payloadWithJob as Parameters<typeof client.createExtractionTask>[0], progressPrefix: string,
); ) {
setTaskCreated(task);
const total = workManifest.length; const total = workManifest.length;
setIngestProgress({ processed: 0, total }); setIngestProgress({ processed: 0, total });
onProgress?.({ onProgress?.({
@@ -438,7 +710,7 @@ export function JobExtractionWizard({
failed: 0, failed: 0,
currentFile: "", currentFile: "",
running: true, running: true,
label: "Preparando envío de documentos…", label: `${progressPrefix}Preparando envío…`,
}); });
const BATCH_SIZE = 20; const BATCH_SIZE = 20;
@@ -458,26 +730,17 @@ export function JobExtractionWizard({
total, total,
processed: queued + fi, processed: queued + fi,
failed: 0, failed: 0,
currentFile: batch[fi].filename, currentFile: batch[fi]!.filename,
running: true, running: true,
label: `Subiendo archivos (${queued + fi}/${total})…`, label: `${progressPrefix}Subiendo (${queued + fi}/${total})…`,
}); });
files.push(await readEntryBytes(batch[fi])); files.push(await readEntryBytes(batch[fi]!));
} }
const blobs = files.map((f) => new Blob([f.data])); 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)); await client.ingestTaskDocumentsBulk(task.id, buildIngestFormBulk(batch, blobs));
queued += batch.length; queued += batch.length;
setIngestProgress({ processed: queued, total }); setIngestProgress({ processed: queued, total });
setStatusText(`Enviando ${queued}/${total} documentos…`); setStatusText(`${progressPrefix}${queued}/${total} documentos…`);
onProgress?.({ onProgress?.({
module: "extraction", module: "extraction",
total, total,
@@ -485,26 +748,125 @@ export function JobExtractionWizard({
failed: 0, failed: 0,
currentFile: batch[batch.length - 1]?.filename ?? "", currentFile: batch[batch.length - 1]?.filename ?? "",
running: true, running: true,
label: `Subiendo archivos (${queued}/${total})…`, label: `${progressPrefix}Subiendo (${queued}/${total})…`,
}); });
void batchIdx;
} }
async function uploadWorker() { async function uploadWorker() {
while (nextBatchIdx < batches.length) { while (nextBatchIdx < batches.length) {
const batchIdx = nextBatchIdx++; const batchIdx = nextBatchIdx++;
await uploadBatch(batches[batchIdx], batchIdx); await uploadBatch(batches[batchIdx]!, batchIdx);
} }
} }
await Promise.all( await Promise.all(
Array.from({ length: Math.min(BATCH_CONCURRENCY, batches.length) }, () => uploadWorker()), Array.from({ length: Math.min(BATCH_CONCURRENCY, batches.length) }, () => uploadWorker()),
); );
setIngestProgress(null); }
setStatusText(`${total} documentos enviados. Procesando en segundo plano…`);
async function runExtraction() {
await runTask("Iniciando extracción...", async () => {
const baseManifest = await resolveBaseManifest();
// Multi-NIT: una task por NIT, secuencial
if (multiNit) {
for (const page of nitSchemas) {
const sel = selectionsByNit[page.nit];
if (!sel || sel.fields.length === 0) {
throw new Error(`Selecciona al menos un campo para NIT ${page.nit}.`);
}
}
const taskIds: string[] = [];
let firstTask: Task | null = null;
const grandTotal = baseManifest.length;
let grandProcessed = 0;
for (let i = 0; i < nitSchemas.length; i++) {
const page = nitSchemas[i]!;
const sel = selectionsByNit[page.nit]!;
const workManifest = baseManifest.filter((m) => (m.nit || "").trim() === page.nit);
if (workManifest.length === 0) continue;
const prefix = `NIT ${i + 1}/${nitSchemas.length} (${page.nit}) · `;
setStatusText(`${prefix}Creando tarea…`);
const payload = buildDesktopBatchPayload(form, sel, workManifest, form.model);
const payloadWithJob = {
...payload,
job_id: job.id,
nit: page.nit,
multi_nit_group: true,
};
const task = await client.createExtractionTask(
payloadWithJob as Parameters<typeof client.createExtractionTask>[0],
);
taskIds.push(task.id);
if (!firstTask) firstTask = task;
onTaskCreated(task);
await ingestManifestToTask(task, workManifest, prefix);
grandProcessed += workManifest.length;
onProgress?.({ onProgress?.({
module: "extraction", module: "extraction",
total, total: grandTotal,
processed: total, processed: grandProcessed,
failed: 0,
currentFile: "",
running: i < nitSchemas.length - 1,
label: `${prefix}Enviado (${workManifest.length} PDF)`,
});
}
if (!firstTask || taskIds.length === 0) {
throw new Error("No se creó ninguna tarea de extracción.");
}
const primary: Task = {
...firstTask,
title: firstTask.title || `Extracción multi-NIT (${taskIds.length})`,
result: {
...(firstTask.result || {}),
child_task_ids: taskIds,
},
};
setChildTaskIds(taskIds);
setTaskCreated(primary);
setIngestProgress(null);
setStatusText(
`${grandProcessed} documentos en ${taskIds.length} NIT. Procesando en segundo plano…`,
);
onProgress?.({
module: "extraction",
total: grandTotal,
processed: grandProcessed,
failed: 0,
currentFile: "",
running: false,
label: "Subida completada — extrayendo datos…",
});
onTaskCreated(primary);
return;
}
// Flujo simple (una task)
if (selection.fields.length === 0) throw new Error("Selecciona al menos un campo.");
const workManifest = baseManifest;
const payload = buildDesktopBatchPayload(form, selection, workManifest, form.model);
const payloadWithJob = { ...payload, job_id: job.id };
const task = await client.createExtractionTask(
payloadWithJob as Parameters<typeof client.createExtractionTask>[0],
);
setChildTaskIds([task.id]);
setTaskCreated(task);
await ingestManifestToTask(task, workManifest, "");
setIngestProgress(null);
setStatusText(`${workManifest.length} documentos enviados. Procesando en segundo plano…`);
onProgress?.({
module: "extraction",
total: workManifest.length,
processed: workManifest.length,
failed: 0, failed: 0,
currentFile: "", currentFile: "",
running: false, running: false,
@@ -531,6 +893,25 @@ export function JobExtractionWizard({
function goToStep(step: ExtractionStep) { function goToStep(step: ExtractionStep) {
setExtractionStep(step); setExtractionStep(step);
setMaxReachedStep((cur) => (step > cur ? step : cur)); setMaxReachedStep((cur) => (step > cur ? step : cur));
if (step === 3 && multiNit) setSelectNitIndex(0);
}
function handleBack() {
if (extractionStep === 3 && multiNit && selectNitIndex > 0) {
setSelectNitIndex((i) => i - 1);
return;
}
goToStep((extractionStep > 1 ? extractionStep - 1 : 1) as ExtractionStep);
}
function handleNext() {
if (!canGoNext) return;
if (extractionStep === 3 && multiNit && selectNitIndex < nitSchemas.length - 1) {
if (effectiveSelection.fields.length === 0) return;
setSelectNitIndex((i) => i + 1);
return;
}
if (extractionStep < 4) goToStep((extractionStep + 1) as ExtractionStep);
} }
function setDocType(index: number, patch: Partial<DocumentType>) { function setDocType(index: number, patch: Partial<DocumentType>) {
@@ -539,8 +920,19 @@ export function JobExtractionWizard({
}); });
} }
function applySelectionUpdate(updater: (cur: FieldSelection) => FieldSelection) {
if (multiNit && activeNit) {
setSelectionsByNit((prev) => {
const cur = prev[activeNit] ?? { fields: [], aliasMap: {} };
return { ...prev, [activeNit]: updater(cur) };
});
return;
}
setSelection(updater);
}
function toggleField(path: string, checked: boolean) { function toggleField(path: string, checked: boolean) {
setSelection((cur) => { applySelectionUpdate((cur) => {
const fields = checked const fields = checked
? Array.from(new Set([...cur.fields, path])) ? Array.from(new Set([...cur.fields, path]))
: cur.fields.filter((f) => f !== path); : cur.fields.filter((f) => f !== path);
@@ -551,15 +943,26 @@ export function JobExtractionWizard({
} }
function selectAllFields() { function selectAllFields() {
setSelection({ applySelectionUpdate((cur) => ({
fields: Object.keys(flattened.schema), fields: Object.keys(flattened.schema),
aliasMap: Object.fromEntries( aliasMap: Object.fromEntries(
Object.keys(flattened.schema).map((p) => [p, selection.aliasMap[p] || defaultAlias(p)]), Object.keys(flattened.schema).map((p) => [p, cur.aliasMap[p] || defaultAlias(p)]),
), ),
}); }));
} }
// Tras crear la tarea: mostrar resultados (con polling) en esta misma pestaña const setSelectionBridge: Dispatch<SetStateAction<FieldSelection>> = (action) => {
applySelectionUpdate((cur) => (typeof action === "function" ? action(cur) : action));
};
const canRediscover = multiNit && activeNit
? manifest.filter((m) => (m.nit || "").trim() === activeNit).length > 1
: form.mode === "general"
? manifest.length > 1
: validDocTypes(form.docTypes).some(
(dt) => manifest.filter((m) => m.docType === dt.name).length > 1,
);
if (taskCreated) { if (taskCreated) {
return ( return (
<div className="job-extraction-results-pane"> <div className="job-extraction-results-pane">
@@ -573,6 +976,7 @@ export function JobExtractionWizard({
client={client} client={client}
job={job} job={job}
task={taskCreated} task={taskCreated}
childTaskIds={childTaskIds.length > 0 ? childTaskIds : undefined}
onTaskUpdated={(t) => { onTaskUpdated={(t) => {
setTaskCreated(t); setTaskCreated(t);
onTaskCreated(t); onTaskCreated(t);
@@ -583,9 +987,10 @@ export function JobExtractionWizard({
); );
} }
const folderCount = countFlatFolders(manifest);
return ( return (
<div className="job-extraction-wrapper"> <div className="job-extraction-wrapper">
{/* ── Banner "desde conteo" — solo cuando viene de archivos válidos del conteo ── */}
{countingTaskId && ( {countingTaskId && (
<div className="from-count-info-banner"> <div className="from-count-info-banner">
<CheckCircle2 size={15} /> <CheckCircle2 size={15} />
@@ -602,6 +1007,21 @@ export function JobExtractionWizard({
</div> </div>
)} )}
{!countingTaskId && docSource === "minio" && manifest.length > 0 && (
<div className="from-count-info-banner">
<Cloud size={15} />
<span>
<strong>Origen MinIO</strong> — {manifest.length} PDF en {folderCount} carpeta(s) planas.
Selección y extracción por NIT · al final un solo CSV.
</span>
<ArrowRight size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
<span className="from-count-info-files">
<FileText size={13} />
{manifest.length} archivos
</span>
</div>
)}
<ExtractionWizard <ExtractionWizard
step={extractionStep} step={extractionStep}
maxReached={maxReachedStep} maxReached={maxReachedStep}
@@ -615,22 +1035,20 @@ export function JobExtractionWizard({
selectRoot={selectRoot} selectRoot={selectRoot}
discoverSchema={() => discoverSchema()} discoverSchema={() => discoverSchema()}
rediscoverWithOtherSample={() => rediscoverWithOtherSample()} rediscoverWithOtherSample={() => rediscoverWithOtherSample()}
discoverySampleLabels={discoverySampleLabels} discoverySampleLabels={
canRediscoverWithOtherSample={ multiNit && nitSchemas[selectNitIndex]
form.mode === "general" ? [nitSchemas[selectNitIndex]!.sampleLabel]
? manifest.length > 1 : discoverySampleLabels
: validDocTypes(form.docTypes).some(
(dt) => manifest.filter((m) => m.docType === dt.name).length > 1,
)
} }
canRediscoverWithOtherSample={canRediscover}
estimate={estimate} estimate={estimate}
runExtraction={runExtraction} runExtraction={runExtraction}
selection={selection} selection={effectiveSelection}
setSelection={setSelection} setSelection={setSelectionBridge}
schemaSearch={schemaSearch} schemaSearch={schemaSearch}
setSchemaSearch={setSchemaSearch} setSchemaSearch={setSchemaSearch}
flattened={flattened} flattened={flattened}
schemaResponse={schemaResponse} schemaResponse={activeSchema}
estimateJson={estimateJson} estimateJson={estimateJson}
manifest={manifest} manifest={manifest}
ingestProgress={ingestProgress} ingestProgress={ingestProgress}
@@ -638,10 +1056,24 @@ export function JobExtractionWizard({
toggleField={toggleField} toggleField={toggleField}
selectAllFields={selectAllFields} selectAllFields={selectAllFields}
canGoNext={canGoNext} canGoNext={canGoNext}
onBack={() => goToStep((extractionStep > 1 ? extractionStep - 1 : 1) as ExtractionStep)} onBack={handleBack}
onNext={() => { if (canGoNext && extractionStep < 4) goToStep((extractionStep + 1) as ExtractionStep); }} onNext={handleNext}
effectiveDocumentCount={countingTaskId ? validCount : undefined} effectiveDocumentCount={countingTaskId ? validCount : undefined}
documentSourceLabel={countingTaskId ? "válidos del conteo" : undefined} documentSourceLabel={
countingTaskId ? "válidos del conteo" : docSource === "minio" ? "desde MinIO" : undefined
}
allowMinioSource={!countingTaskId}
docSource={docSource}
onDocSourceChange={handleDocSourceChange}
selectedDownloadJobId={resolvedDownloadJobId}
onSelectedDownloadJobIdChange={setResolvedDownloadJobId}
onMinioIndexed={handleMinioIndexed}
minioIndexing={minioIndexing}
client={client}
parentJobId={job.id}
nitPages={nitPages}
selectNitIndex={selectNitIndex}
onSelectNitIndexChange={setSelectNitIndex}
/> />
</div> </div>
); );
+323 -1
View File
@@ -1984,6 +1984,15 @@ th {
gap: 10px; gap: 10px;
} }
.folder-picker .form-input {
flex: 1;
min-width: 0;
}
.minio-source-picker {
margin-top: 4px;
}
.folder-picker-display { .folder-picker-display {
align-items: center; align-items: center;
background: #f8fafc; background: #f8fafc;
@@ -4237,7 +4246,8 @@ th {
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden; overflow-x: hidden;
overflow-y: auto;
} }
.results-section-header { .results-section-header {
@@ -4264,6 +4274,318 @@ th {
gap: 6px; 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 ── */ /* ── Extracción desde conteo: wrapper y banner informativo ── */
.job-extraction-wrapper { .job-extraction-wrapper {
display: flex; display: flex;
@@ -7,7 +7,10 @@ beforeEach(() => {
getRuntimeConfig: vi.fn().mockResolvedValue({ documentRootLocal: "C:\\docs", documentRootContainer: "/data" }), getRuntimeConfig: vi.fn().mockResolvedValue({ documentRootLocal: "C:\\docs", documentRootContainer: "/data" }),
selectDirectory: vi.fn(), selectDirectory: vi.fn(),
saveCsvDialog: vi.fn(), saveCsvDialog: vi.fn(),
saveFileDialog: vi.fn(),
writeTextFile: vi.fn(), writeTextFile: vi.fn(),
writeBytesFile: vi.fn(),
joinPath: vi.fn(async (...parts: string[]) => parts.join("\\")),
findFirstDocument: vi.fn(), findFirstDocument: vi.fn(),
indexFolder: vi.fn().mockResolvedValue([ indexFolder: vi.fn().mockResolvedValue([
{ radicado: "R1", filename: "factura.pdf", docType: "Factura", localPath: "C:\\docs\\R1\\factura.pdf", size: 10 }, { 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>; getRuntimeConfig(): Promise<RuntimeConfig>;
selectDirectory(): Promise<string | null>; selectDirectory(): Promise<string | null>;
saveCsvDialog(defaultPath?: string): 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 }>; 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>; findFirstDocument(rootPath: string, keywords: string[]): Promise<string | null>;
indexFolder( indexFolder(
rootPath: string, rootPath: string,