Files
IPSExtractor/services/desktop_app/src/renderer/modules/cleaning/CleaningWizard.tsx
T

1087 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AlertCircle,
ArrowRight,
CheckCircle2,
Cpu,
Download,
FileSpreadsheet,
Loader2,
Play,
Sparkles,
Upload,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type {
BackgroundProgress,
CleaningAnalysisResponse,
CleaningColumnAnalysis,
CleaningJob,
CleaningPreviewColumn,
CleaningPreviewResponse,
CleaningRuleInfo,
Job,
Task,
} from "../../../shared/types";
import { CleaningStepper, type CleaningStep } from "../../components/CleaningStepper";
import { ExtractionResultsTable } from "../../components/ExtractionResultsTable";
import type { ApiClient } from "../../lib/apiClient";
import { loadCopModConfig } from "../../lib/cleaningSettings";
import { downloadCsv, toCsv, buildExportCsvFilename } from "../../lib/csv";
import { taskTimingLabel, timingDuration } from "../../lib/time";
type SourceMode = "extraction" | "csv";
interface Props {
client: ApiClient;
job: Job;
extractionBatchId?: string;
extractionTaskId?: string;
onTaskCreated?: (task: Task) => void;
onProgress?: (progress: BackgroundProgress) => void;
}
function batchIdFromTask(task: Task): string {
return String((task.result as Record<string, unknown>)?.extraction_batch_id ?? "");
}
export function CleaningWizard({
client,
job,
extractionBatchId: initialBatchId,
extractionTaskId: initialTaskId,
onTaskCreated,
onProgress,
}: Props) {
const hasHandoff = Boolean(initialBatchId || initialTaskId);
const [step, setStep] = useState<CleaningStep>(1);
const [maxReached, setMaxReached] = useState<CleaningStep>(1);
const [source, setSource] = useState<SourceMode>(hasHandoff ? "extraction" : "extraction");
const [extractionTasks, setExtractionTasks] = useState<Task[]>([]);
const [loadingTasks, setLoadingTasks] = useState(true);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(initialTaskId ?? null);
const [batchId, setBatchId] = useState(initialBatchId ?? "");
const [csvFile, setCsvFile] = useState<File | null>(null);
const [rulesCatalog, setRulesCatalog] = useState<CleaningRuleInfo[]>([]);
const [analysis, setAnalysis] = useState<CleaningAnalysisResponse | null>(null);
const [rulesPerColumn, setRulesPerColumn] = useState<Record<string, string[]>>({});
const [copModFechaColumn, setCopModFechaColumn] = useState<Record<string, string>>({});
const [copModBrutoColumn, setCopModBrutoColumn] = useState<Record<string, string>>({});
const [copModNetoColumn, setCopModNetoColumn] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [resultJob, setResultJob] = useState<CleaningJob | null>(null);
const [resultRows, setResultRows] = useState<Record<string, unknown>[]>([]);
const [handoffAnalyzed, setHandoffAnalyzed] = useState(false);
const [preview, setPreview] = useState<CleaningPreviewResponse | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
const [sampleMode, setSampleMode] = useState<"before" | "after">("before");
const fileInputRef = useRef<HTMLInputElement>(null);
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
function goToStep(next: CleaningStep) {
setStep(next);
setMaxReached((m) => (next > m ? next : m));
}
useEffect(() => {
client.cleaningRules().then(setRulesCatalog).catch(() => setRulesCatalog([]));
}, [client]);
useEffect(() => {
let cancelled = false;
async function loadExtractions() {
setLoadingTasks(true);
try {
const tasks = await client.jobTasks(job.id);
if (cancelled) return;
const extractions = tasks
.filter((t) => t.type === "extraction" && t.status === "completed")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
setExtractionTasks(extractions);
// Preselección por handoff o la más reciente
if (initialTaskId && extractions.some((t) => t.id === initialTaskId)) {
const t = extractions.find((x) => x.id === initialTaskId)!;
setSelectedTaskId(t.id);
setBatchId(batchIdFromTask(t) || initialBatchId || "");
setSource("extraction");
} else if (initialBatchId) {
const match = extractions.find((t) => batchIdFromTask(t) === initialBatchId);
if (match) {
setSelectedTaskId(match.id);
setBatchId(initialBatchId);
} else {
setBatchId(initialBatchId);
}
setSource("extraction");
} else if (extractions.length > 0 && !selectedTaskId) {
const newest = extractions[0];
setSelectedTaskId(newest.id);
setBatchId(batchIdFromTask(newest));
}
} catch {
if (!cancelled) setExtractionTasks([]);
} finally {
if (!cancelled) setLoadingTasks(false);
}
}
void loadExtractions();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, job.id, initialBatchId, initialTaskId]);
// Handoff: analizar automáticamente al llegar desde Resultados
useEffect(() => {
if (!hasHandoff || handoffAnalyzed || loadingTasks) return;
const id = batchId || initialBatchId;
if (!id) return;
setHandoffAnalyzed(true);
void loadAnalysis("extraction", id, null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasHandoff, handoffAnalyzed, loadingTasks, batchId, initialBatchId]);
function selectExtraction(task: Task) {
setSelectedTaskId(task.id);
setBatchId(batchIdFromTask(task));
setSource("extraction");
setError(null);
}
function ruleName(id: string): string {
return rulesCatalog.find((r) => r.id === id)?.name ?? id;
}
function guessFechaColumn(columns: CleaningColumnAnalysis[], amountCol: string): string {
const candidates = columns.filter(
(c) =>
c.name !== amountCol &&
(c.inferred_type === "date" ||
/fecha|date|servicio|fec[_ ]/i.test(c.name)),
);
const preferred =
candidates.find((c) => /fecha\s*servicio|fec[_ ]?serv/i.test(c.name)) ??
candidates.find((c) => c.inferred_type === "date") ??
candidates[0];
return preferred?.name ?? "";
}
function guessBrutoColumn(columns: CleaningColumnAnalysis[], preferCol?: string): string {
if (preferCol && /bruto|total[_\s]?cargos|valor[_\s]?bruto|valor[_\s]?factura/i.test(preferCol)) {
return preferCol;
}
const preferred =
columns.find((c) => /valor[_\s]?bruto|^bruto$|total[_\s]?cargos/i.test(c.name)) ??
columns.find((c) => /bruto/i.test(c.name)) ??
(preferCol ? columns.find((c) => c.name === preferCol) : undefined);
return preferred?.name ?? preferCol ?? "";
}
function guessNetoColumn(columns: CleaningColumnAnalysis[], brutoCol: string): string {
const preferred =
columns.find(
(c) => c.name !== brutoCol && /valor[_\s]?neto|^neto$|valor[_\s]?pagado/i.test(c.name),
) ??
columns.find((c) => c.name !== brutoCol && /neto/i.test(c.name));
return preferred?.name ?? "";
}
function toggleRule(column: string, ruleId: string) {
setRulesPerColumn((prev) => {
const current = prev[column] ?? [];
const turningOn = !current.includes(ruleId);
const next = turningOn
? [...current, ruleId]
: current.filter((r) => r !== ruleId);
return { ...prev, [column]: next };
});
if (ruleId === "cop_mod_split") {
setCopModFechaColumn((prev) => {
const current = rulesPerColumn[column] ?? [];
const turningOn = !current.includes(ruleId);
if (!turningOn) {
const next = { ...prev };
delete next[column];
return next;
}
if (prev[column]) return prev;
const guess = analysis ? guessFechaColumn(analysis.columns, column) : "";
return guess ? { ...prev, [column]: guess } : prev;
});
}
if (ruleId === "cop_mod_bruto_neto") {
const current = rulesPerColumn[column] ?? [];
const turningOn = !current.includes(ruleId);
if (!turningOn) {
setCopModBrutoColumn((prev) => {
const next = { ...prev };
delete next[column];
return next;
});
setCopModNetoColumn((prev) => {
const next = { ...prev };
delete next[column];
return next;
});
} else if (analysis) {
const bruto = guessBrutoColumn(analysis.columns, column);
const neto = guessNetoColumn(analysis.columns, bruto || column);
setCopModBrutoColumn((prev) => (prev[column] ? prev : { ...prev, [column]: bruto || column }));
setCopModNetoColumn((prev) => (prev[column] ? prev : { ...prev, [column]: neto }));
}
}
setSampleMode("after");
}
function suggestRules(columns: CleaningColumnAnalysis[]) {
const suggested: Record<string, string[]> = {};
for (const col of columns) {
const applicable = col.applicable_rules ?? [];
suggested[col.name] = applicable.includes("strip_spaces") ? ["strip_spaces"] : [];
}
setRulesPerColumn(suggested);
setCopModFechaColumn({});
setCopModBrutoColumn({});
setCopModNetoColumn({});
}
function cleaningExtraPayload(): {
cop_mod_fecha_column?: Record<string, string>;
cop_mod_bruto_column?: Record<string, string>;
cop_mod_neto_column?: Record<string, string>;
cop_mod_config: ReturnType<typeof loadCopModConfig>;
} {
const fechaEntries = Object.entries(copModFechaColumn).filter(
([col, fecha]) =>
Boolean(fecha) && (rulesPerColumn[col] ?? []).includes("cop_mod_split"),
);
const brutoEntries = Object.entries(copModBrutoColumn).filter(
([col, bruto]) =>
Boolean(bruto) && (rulesPerColumn[col] ?? []).includes("cop_mod_bruto_neto"),
);
const netoEntries = Object.entries(copModNetoColumn).filter(
([col, neto]) =>
Boolean(neto) && (rulesPerColumn[col] ?? []).includes("cop_mod_bruto_neto"),
);
return {
...(fechaEntries.length > 0
? { cop_mod_fecha_column: Object.fromEntries(fechaEntries) }
: {}),
...(brutoEntries.length > 0
? { cop_mod_bruto_column: Object.fromEntries(brutoEntries) }
: {}),
...(netoEntries.length > 0
? { cop_mod_neto_column: Object.fromEntries(netoEntries) }
: {}),
cop_mod_config: loadCopModConfig(),
};
}
function previewColumn(name: string): CleaningPreviewColumn | undefined {
return preview?.columns.find((c) => c.name === name);
}
function displaySamples(col: CleaningColumnAnalysis): string[] {
if (col.samples && col.samples.length > 0) return col.samples;
if (col.example) return [String(col.example)];
return [];
}
/** Columnas nuevas respecto al análisis (p. ej. *_doc_tipo / *_doc_numeros / cop/mod). */
function derivedColumnNames(sourceCol: string): string[] {
if (!preview) return [];
const original = new Set(analysis?.columns.map((c) => c.name) ?? []);
const selected = rulesPerColumn[sourceCol] ?? [];
return preview.columns
.map((c) => c.name)
.filter((name) => {
if (original.has(name)) return false;
if (name.toLowerCase().startsWith(`${sourceCol.toLowerCase()}_`)) return true;
if (
selected.includes("cop_mod_bruto_neto") &&
(name === "cop/mod" || name.startsWith("cop/mod_"))
) {
return true;
}
return false;
});
}
/** Orden: columna activa → columnas derivadas → resto (sin cortar). */
function orderedSampleColumnNames(mode: "before" | "after"): string[] {
const analysisNames = analysis?.columns.map((c) => c.name) ?? [];
const names =
mode === "after" && preview
? preview.columns.map((c) => c.name)
: analysisNames;
const focus = activeColumn;
if (!focus || !names.includes(focus)) return names;
const derived = names.filter(
(n) => n !== focus && n.toLowerCase().startsWith(`${focus.toLowerCase()}_`),
);
const rest = names.filter((n) => n !== focus && !derived.includes(n));
return [focus, ...derived, ...rest];
}
// Preview en vivo al cambiar reglas (debounce 300ms)
useEffect(() => {
if (step !== 2 || !analysis) return;
const sampleRows = analysis.sample_rows ?? [];
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
previewTimerRef.current = setTimeout(() => {
void (async () => {
setPreviewLoading(true);
setPreviewError(null);
try {
const extra = cleaningExtraPayload();
const payload =
sampleRows.length > 0
? { rows: sampleRows, rules_per_column: rulesPerColumn, ...extra }
: batchId
? {
extraction_batch_id: batchId,
rules_per_column: rulesPerColumn,
...extra,
}
: null;
if (!payload) {
setPreview(null);
return;
}
const data = await client.previewCleaning(payload);
setPreview(data);
} catch (e) {
setPreviewError(String(e instanceof Error ? e.message : e));
} finally {
setPreviewLoading(false);
}
})();
}, 300);
return () => {
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
};
}, [step, analysis, rulesPerColumn, copModFechaColumn, copModBrutoColumn, copModNetoColumn, batchId, client]);
async function loadAnalysis(mode: SourceMode, batch?: string, file?: File | null) {
setLoading(true);
setError(null);
setAnalysis(null);
onProgress?.({
module: "cleaning",
total: 1,
processed: 0,
failed: 0,
currentFile: mode === "csv" ? (file?.name ?? "CSV") : "extracción",
running: true,
label: "Analizando columnas…",
});
try {
let data: CleaningAnalysisResponse;
if (mode === "extraction") {
if (!batch) throw new Error("Selecciona una extracción de la lista");
data = await client.analyzeCleaningBatch(batch);
} else {
if (!file) throw new Error("Selecciona un archivo CSV");
data = await client.analyzeCleaningCsv(file, file.name);
}
setAnalysis(data);
suggestRules(data.columns);
setActiveColumn(data.columns[0]?.name ?? null);
setSampleMode("before");
setPreview(null);
goToStep(2);
} catch (e) {
setError(String(e instanceof Error ? e.message : e));
} finally {
setLoading(false);
onProgress?.({
module: "cleaning",
total: 1,
processed: 1,
failed: 0,
currentFile: "",
running: false,
label: "",
});
}
}
async function handleAnalyze() {
await loadAnalysis(source, batchId, csvFile);
}
async function handleRun() {
setRunning(true);
setError(null);
setResultJob(null);
setResultRows([]);
onProgress?.({
module: "cleaning",
total: 1,
processed: 0,
failed: 0,
currentFile: source === "csv" ? (csvFile?.name ?? "CSV") : "extracción",
running: true,
label: "Aplicando reglas de limpieza…",
});
try {
let cleaningJob: CleaningJob;
if (source === "extraction") {
if (!batchId) throw new Error("Selecciona una extracción");
cleaningJob = await client.runCleaning({
extraction_batch_id: batchId,
job_id: job.id,
rules_per_column: rulesPerColumn,
...cleaningExtraPayload(),
});
} else {
if (!csvFile) throw new Error("Selecciona un archivo CSV");
const form = new FormData();
const extra = cleaningExtraPayload();
form.append("file", csvFile, csvFile.name);
form.append("job_id", job.id);
form.append("rules_per_column", JSON.stringify(rulesPerColumn));
if (extra.cop_mod_fecha_column) {
form.append("cop_mod_fecha_column", JSON.stringify(extra.cop_mod_fecha_column));
}
if (extra.cop_mod_bruto_column) {
form.append("cop_mod_bruto_column", JSON.stringify(extra.cop_mod_bruto_column));
}
if (extra.cop_mod_neto_column) {
form.append("cop_mod_neto_column", JSON.stringify(extra.cop_mod_neto_column));
}
form.append("cop_mod_config", JSON.stringify(extra.cop_mod_config));
cleaningJob = await client.runCleaningFromCsv(form);
}
setResultJob(cleaningJob);
if (cleaningJob.status === "completed") {
const rowsResp = await client.cleaningJobRows(cleaningJob.id, 500, 0);
setResultRows(rowsResp.rows ?? []);
}
try {
const tasks = await client.jobTasks(job.id);
const latest = tasks
.filter((t) => t.type === "cleaning")
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
if (latest) onTaskCreated?.(latest);
} catch { /* silent */ }
goToStep(3);
} catch (e) {
setError(String(e instanceof Error ? e.message : e));
} finally {
setRunning(false);
onProgress?.({
module: "cleaning",
total: 1,
processed: 1,
failed: 0,
currentFile: "",
running: false,
label: "",
});
}
}
function exportCsv() {
if (resultRows.length === 0) return;
const columns = Array.from(new Set(resultRows.flatMap((r) => Object.keys(r))));
const csv = toCsv(
columns,
resultRows.map((r) => columns.map((h) => r[h])),
);
downloadCsv(buildExportCsvFilename("limpieza", job.title), csv);
}
function resetWizard() {
setStep(1);
setMaxReached(1);
setResultJob(null);
setResultRows([]);
setAnalysis(null);
setHandoffAnalyzed(true); // no re-auto-analizar
setError(null);
}
const resultColumns =
resultRows.length > 0
? Array.from(new Set(resultRows.flatMap((r) => Object.keys(r))))
: [];
const selectedExtraction = extractionTasks.find((t) => t.id === selectedTaskId) ?? null;
const canAnalyze =
source === "extraction" ? Boolean(batchId) : Boolean(csvFile);
return (
<div className="wiz">
<CleaningStepper current={step} maxReached={maxReached} onStepClick={goToStep} />
<div className="wiz-body">
{error && (
<div className="wiz-toast wiz-toast--error" style={{ marginBottom: 12 }}>
<AlertCircle size={14} /> {error}
</div>
)}
{/* ── PASO 1: Fuente ── */}
{step === 1 && (
<div className="wiz-step">
<div className="wiz-section">
<h3 className="wiz-section-title">Origen de los datos</h3>
<p className="wiz-desc">
Elige una extracción completada de este trabajo o sube un CSV para limpiar.
</p>
</div>
<div className="wiz-section">
<div className="wiz-checks-row" style={{ gap: 10, flexWrap: "wrap" }}>
<button
type="button"
className={`btn${source === "extraction" ? " btn-primary" : ""}`}
onClick={() => setSource("extraction")}
>
<Cpu size={14} /> Desde extracción
</button>
<button
type="button"
className={`btn${source === "csv" ? " btn-primary" : ""}`}
onClick={() => setSource("csv")}
>
<Upload size={14} /> Subir CSV
</button>
</div>
</div>
{source === "extraction" ? (
<div className="wiz-section">
<label className="wiz-field-label">Extracciones de este trabajo</label>
{loadingTasks ? (
<div className="module-loading" style={{ padding: 24 }}>
<Loader2 size={18} className="spin" /> Cargando extracciones…
</div>
) : extractionTasks.length === 0 ? (
<div className="wiz-found-badge" style={{ flexDirection: "column", alignItems: "flex-start", gap: 8 }}>
<span>No hay extracciones completadas en este trabajo.</span>
<button type="button" className="btn btn-sm" onClick={() => setSource("csv")}>
<Upload size={13} /> Subir un CSV en su lugar
</button>
</div>
) : (
<div className="cleaning-source-list" role="listbox" aria-label="Extracciones">
{extractionTasks.map((task, index) => {
const selected = task.id === selectedTaskId;
return (
<button
key={task.id}
type="button"
role="option"
aria-selected={selected}
className={`cleaning-source-item${selected ? " cleaning-source-item--selected" : ""}`}
onClick={() => selectExtraction(task)}
>
<div className="cleaning-source-item-main">
<FileSpreadsheet size={16} />
<div>
<div className="cleaning-source-item-title">
{task.title ?? `Extracción ${extractionTasks.length - index}`}
</div>
<div className="cleaning-source-item-meta">
{taskTimingLabel(task)}
{index === 0 ? " · más reciente" : ""}
</div>
</div>
</div>
<span className={`task-badge task-badge--${task.status}`}>{task.status}</span>
</button>
);
})}
</div>
)}
{selectedExtraction && (
<p className="wiz-hint" style={{ marginTop: 8 }}>
Seleccionada: <strong>{selectedExtraction.title ?? "Extracción"}</strong>
</p>
)}
</div>
) : (
<div className="wiz-section">
<label className="wiz-field-label">Archivo CSV</label>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
style={{ display: "none" }}
onChange={(e) => setCsvFile(e.target.files?.[0] ?? null)}
/>
<div className="folder-picker">
<div className="folder-picker-display">
{csvFile ? (
<>
<Upload size={16} className="folder-picker-icon" />
<span className="folder-picker-path">{csvFile.name}</span>
</>
) : (
<span className="folder-picker-placeholder">Ningún archivo seleccionado</span>
)}
</div>
<button type="button" className="btn btn-primary" onClick={() => fileInputRef.current?.click()}>
<Upload size={15} /> Explorar
</button>
</div>
</div>
)}
<div className="wiz-nav" style={{ marginTop: 8 }}>
<button
type="button"
className="btn btn-primary"
disabled={loading || !canAnalyze}
onClick={() => void handleAnalyze()}
>
{loading ? <Loader2 size={14} className="spin" /> : <Play size={14} />}
Analizar columnas
</button>
</div>
</div>
)}
{/* ── PASO 2: Reglas ── */}
{step === 2 && (
<div className="wiz-step wiz-step--cleaning-rules">
<div className="cleaning-rules-toolbar">
<div>
<h3 className="wiz-section-title" style={{ margin: 0 }}>Reglas por columna</h3>
<p className="wiz-desc" style={{ margin: "4px 0 0" }}>
Elige una columna, aplica reglas y mira el cambio al instante.
{analysis?.row_count != null ? ` · ${analysis.row_count} filas` : ""}
{previewLoading ? " · actualizando…" : ""}
</p>
</div>
{previewLoading && <Loader2 size={16} className="spin" />}
</div>
{loading && !analysis ? (
<div className="module-loading">
<Loader2 size={20} className="spin" /> Analizando…
</div>
) : analysis ? (
<div className="cleaning-rules-workspace">
{/* Lista compacta de columnas */}
<aside className="cleaning-col-nav" aria-label="Columnas">
{analysis.columns.map((col) => {
const selected = (rulesPerColumn[col.name] ?? []).length;
const changed = Boolean(previewColumn(col.name)?.changed);
const isActive = activeColumn === col.name;
return (
<button
key={col.name}
type="button"
className={`cleaning-col-nav-item${isActive ? " cleaning-col-nav-item--active" : ""}${changed ? " cleaning-col-nav-item--changed" : ""}`}
onClick={() => setActiveColumn(col.name)}
>
<span className="cleaning-col-nav-name">{col.name}</span>
<span className="cleaning-col-nav-meta">
<span className="cleaning-type-badge">{col.inferred_type}</span>
{selected > 0 && (
<span className="cleaning-col-nav-count">{selected}</span>
)}
</span>
</button>
);
})}
</aside>
{/* Detalle de la columna activa */}
<div className="cleaning-col-detail">
{previewError && (
<div className="wiz-toast wiz-toast--error" style={{ marginBottom: 8 }}>
<AlertCircle size={14} /> {previewError}
</div>
)}
{(() => {
const col = analysis.columns.find((c) => c.name === activeColumn) ?? analysis.columns[0];
if (!col) return <p className="wiz-desc">Sin columnas.</p>;
const applicable = col.applicable_rules ?? [];
const selected = rulesPerColumn[col.name] ?? [];
const beforeFromSamples = displaySamples(col);
const pv = previewColumn(col.name);
const beforeVals = pv?.before?.length ? pv.before.slice(0, 4) : beforeFromSamples.slice(0, 4);
const derived = derivedColumnNames(col.name);
const afterVals = pv?.after?.length
? pv.after.slice(0, 4)
: selected.length === 0
? beforeVals
: [];
const sourceUnchanged =
beforeVals.length > 0 &&
afterVals.length > 0 &&
beforeVals.every((v, i) => String(v) === String(afterVals[i] ?? ""));
const changed = Boolean(pv?.changed) || derived.length > 0;
return (
<>
<div className="cleaning-col-detail-header">
<div>
<h4 className="cleaning-col-detail-title">{col.name}</h4>
<div className="cleaning-rule-card-meta">
<span className="cleaning-type-badge">{col.inferred_type}</span>
{col.null_count != null && (
<span>nulos {col.null_count}{col.null_pct != null ? ` (${Math.round(col.null_pct)}%)` : ""}</span>
)}
{col.unique_count != null && <span>únicos {col.unique_count}</span>}
</div>
</div>
</div>
<div className={`cleaning-preview-compare cleaning-preview-compare--compact${changed ? " cleaning-preview-compare--changed" : ""}`}>
<div className="cleaning-preview-side">
<div className="cleaning-preview-label">Antes</div>
<div className="cleaning-preview-values">
{beforeVals.length === 0 ? (
<span className="cleaning-preview-empty">Sin valores</span>
) : (
beforeVals.map((v, i) => (
<code key={i} className="cleaning-preview-chip">{v || "—"}</code>
))
)}
</div>
</div>
<ArrowRight size={14} className="cleaning-preview-arrow" />
<div className="cleaning-preview-side">
<div className={`cleaning-preview-label${changed ? " cleaning-preview-label--after" : ""}`}>
Después
</div>
<div className="cleaning-preview-values">
{derived.length > 0 ? (
derived.map((dName) => {
const dPv = previewColumn(dName);
const sample = (dPv?.after ?? []).find((v) => v) ?? (dPv?.after?.[0] ?? "");
return (
<code key={dName} className="cleaning-preview-chip cleaning-preview-chip--changed" title={dName}>
<span className="cleaning-preview-chip-col">{dName.replace(/^.*_doc_/, "_doc_")}</span>
{sample || "—"}
</code>
);
})
) : afterVals.length === 0 ? (
<span className="cleaning-preview-empty">
{previewLoading ? "…" : "Activa una regla"}
</span>
) : sourceUnchanged ? (
<span className="cleaning-preview-empty">Sin cambio en origen</span>
) : (
afterVals.map((v, i) => {
const before = beforeVals[i] ?? "";
const isDiff = String(v) !== String(before);
return (
<code
key={i}
className={`cleaning-preview-chip${isDiff ? " cleaning-preview-chip--changed" : ""}`}
>
{v || "—"}
</code>
);
})
)}
</div>
</div>
</div>
<div className="cleaning-rules-block">
<div className="cleaning-preview-label">Reglas</div>
{applicable.length === 0 ? (
<span className="wiz-hint">Sin reglas para este tipo</span>
) : (
<div className="cleaning-rule-chips">
{applicable.map((ruleId) => {
const on = selected.includes(ruleId);
const info = rulesCatalog.find((r) => r.id === ruleId);
return (
<button
key={ruleId}
type="button"
className={`btn btn-sm${on ? " btn-primary" : ""}`}
title={info?.description || ruleId}
onClick={() => toggleRule(col.name, ruleId)}
>
{ruleName(ruleId)}
</button>
);
})}
</div>
)}
{selected.length > 0 && (
<p className="wiz-hint cleaning-rule-desc">
{selected
.map((id) => rulesCatalog.find((r) => r.id === id)?.description || id)
.join(" · ")}
</p>
)}
{selected.includes("cop_mod_split") && (
<div className="cleaning-copmod-fecha">
<label className="wiz-field-label" htmlFor={`copmod-fecha-${col.name}`}>
Fecha Servicio (para COP/MOD)
</label>
<select
id={`copmod-fecha-${col.name}`}
className="form-input"
value={copModFechaColumn[col.name] ?? ""}
onChange={(e) =>
setCopModFechaColumn((prev) => ({
...prev,
[col.name]: e.target.value,
}))
}
>
<option value="">Selecciona columna de fecha…</option>
{analysis.columns
.filter((c) => c.name !== col.name)
.map((c) => (
<option key={c.name} value={c.name}>
{c.name}
{c.inferred_type === "date" ? " (fecha)" : ""}
</option>
))}
</select>
<p className="form-hint">
Las cuotas por año se editan en Configuración → Limpieza.
</p>
</div>
)}
{selected.includes("cop_mod_bruto_neto") && (
<div className="cleaning-copmod-fecha">
<label className="wiz-field-label" htmlFor={`copmod-bruto-${col.name}`}>
Valor bruto
</label>
<select
id={`copmod-bruto-${col.name}`}
className="form-input"
value={copModBrutoColumn[col.name] ?? col.name}
onChange={(e) =>
setCopModBrutoColumn((prev) => ({
...prev,
[col.name]: e.target.value,
}))
}
>
{analysis.columns.map((c) => (
<option key={c.name} value={c.name}>
{c.name}
</option>
))}
</select>
<label
className="wiz-field-label"
htmlFor={`copmod-neto-${col.name}`}
style={{ marginTop: 10 }}
>
Valor neto
</label>
<select
id={`copmod-neto-${col.name}`}
className="form-input"
value={copModNetoColumn[col.name] ?? ""}
onChange={(e) =>
setCopModNetoColumn((prev) => ({
...prev,
[col.name]: e.target.value,
}))
}
>
<option value="">Selecciona columna neto…</option>
{analysis.columns
.filter(
(c) =>
c.name !== (copModBrutoColumn[col.name] ?? col.name),
)
.map((c) => (
<option key={c.name} value={c.name}>
{c.name}
</option>
))}
</select>
<p className="form-hint">
Se crea la columna <strong>cop/mod</strong> = bruto − neto.
</p>
</div>
)}
</div>
</>
);
})()}
{/* Una sola tabla de muestra: Antes | Después */}
{((analysis.sample_rows?.length ?? 0) > 0 || (preview?.rows_after.length ?? 0) > 0) && (
<div className="cleaning-sample-dock">
<div className="cleaning-sample-dock-header">
<strong>Muestra</strong>
<div className="cleaning-sample-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={sampleMode === "before"}
className={`cleaning-sample-tab${sampleMode === "before" ? " cleaning-sample-tab--active" : ""}`}
onClick={() => setSampleMode("before")}
>
Antes
</button>
<button
type="button"
role="tab"
aria-selected={sampleMode === "after"}
className={`cleaning-sample-tab${sampleMode === "after" ? " cleaning-sample-tab--active" : ""}`}
onClick={() => setSampleMode("after")}
disabled={!preview}
>
Después
</button>
</div>
</div>
<div className="cleaning-sample-table-scroll">
<table className="results-table cleaning-preview-table">
<thead>
<tr>
{orderedSampleColumnNames(sampleMode).map((name) => {
const isNew = !(analysis.columns.some((c) => c.name === name));
const changed = preview?.columns.find((x) => x.name === name)?.changed;
return (
<th
key={name}
className={
sampleMode === "after" && (changed || isNew)
? "cleaning-th-changed"
: undefined
}
>
{name}
</th>
);
})}
</tr>
</thead>
<tbody>
{(sampleMode === "after" && preview
? preview.rows_after
: analysis.sample_rows ?? preview?.rows_before ?? []
).slice(0, 4).map((row, i) => (
<tr key={i}>
{orderedSampleColumnNames(sampleMode).map((name) => {
const val = row[name];
const before = preview?.rows_before[i]?.[name];
const isNew = !(analysis.columns.some((c) => c.name === name));
const diff =
sampleMode === "after" &&
(isNew || String(before ?? "") !== String(val ?? ""));
return (
<td key={name} className={diff ? "cleaning-td-changed" : undefined}>
{val == null || val === ""
? <span className="cleaning-preview-empty">—</span>
: String(val).slice(0, 36)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
) : (
<p className="wiz-desc">No hay análisis. Vuelve al paso Fuente.</p>
)}
<div className="wiz-nav cleaning-rules-nav">
<button type="button" className="btn" onClick={() => goToStep(1)} disabled={running}>
Atrás
</button>
<button
type="button"
className="btn btn-primary"
disabled={running || !analysis}
onClick={() => void handleRun()}
>
{running ? <Loader2 size={14} className="spin" /> : <Sparkles size={14} />}
Ejecutar limpieza
</button>
</div>
</div>
)}
{/* ── PASO 3: Resultados ── */}
{step === 3 && (
<div className="wiz-step">
<div className="wiz-section">
<h3 className="wiz-section-title">Resultado de la limpieza</h3>
</div>
{resultJob?.status === "completed" ? (
<>
<div className="wiz-found-badge" style={{ marginBottom: 12 }}>
<CheckCircle2 size={15} />
<span>
Limpieza completada
{resultJob.log?.length ? ` · ${resultJob.log.length} operaciones` : null}
{resultRows.length > 0 ? ` · ${resultRows.length} filas` : null}
{` · duración ${timingDuration(resultJob)}`}
</span>
</div>
{resultJob.log && resultJob.log.length > 0 && (
<details className="wiz-advanced" style={{ marginBottom: 12 }}>
<summary className="wiz-advanced-trigger">Ver log de operaciones</summary>
<div className="wiz-advanced-body">
<ul style={{ margin: 0, paddingLeft: 18, color: "var(--text-muted)", fontSize: 12 }}>
{resultJob.log.map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
</div>
</details>
)}
<div className="module-results-header" style={{ marginBottom: 8 }}>
<span style={{ fontSize: 13, color: "var(--text-muted)" }}>
Vista previa{resultRows.length > 100 ? " (primeras 100)" : ""}
</span>
{resultRows.length > 0 && (
<button type="button" className="btn btn-sm btn-ghost" onClick={exportCsv}>
<Download size={13} /> Descargar CSV
</button>
)}
</div>
{resultRows.length > 0 ? (
<ExtractionResultsTable rows={resultRows} columns={resultColumns} />
) : (
<p className="wiz-desc">Sin filas en el resultado.</p>
)}
</>
) : (
<div className="wiz-toast wiz-toast--error">
<AlertCircle size={14} />
{resultJob?.error ?? "La limpieza no se completó"}
</div>
)}
<div className="wiz-nav" style={{ marginTop: 16, gap: 8 }}>
<button type="button" className="btn" onClick={() => goToStep(2)}>
Atrás
</button>
<button type="button" className="btn btn-primary" onClick={resetWizard}>
Nueva limpieza
</button>
</div>
</div>
)}
</div>
</div>
);
}