1087 lines
46 KiB
TypeScript
1087 lines
46 KiB
TypeScript
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>
|
||
);
|
||
}
|