Files
plantillas-proyectos/frontend/src/routes/dashboard/csv-upload/+page.svelte
2026-05-06 14:24:41 -06:00

1393 lines
46 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import UploadLauncherGrid from '$lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte';
import CsvParamsBar from '$lib/components/dashboard/csv-upload/CsvParamsBar.svelte';
import ProcessingResultModal from '$lib/components/dashboard/csv-upload/ProcessingResultModal.svelte';
import {
catalogosConfig,
transportesConfig,
importacionConfig,
exportacionConfig,
tabSettings,
globalCsvParams,
type CsvUploadItem
} from '$lib/config/csv-upload';
import { api } from '$lib/api';
import { Progress } from '$lib/components/ui/progress/index.js';
import { toast } from 'svelte-sonner';
import { companyStore } from '$lib/stores/company.svelte';
import { currentUser, userHasPermission } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
const canProcessCsv = $derived(userHasPermission($currentUser, 'csv_upload.process'));
import {
CSV_IMPORT_SESSION_KEY,
clearCsvImportSession,
readCsvImportSession,
saveCsvImportSession,
type CsvImportProfile,
type CsvImportSessionV1
} from '$lib/csv-import-session';
import {
removeCsvImportPending,
upsertCsvImportPending,
type CsvImportPendingEntry
} from '$lib/csv-import-pending';
import { fetchCsvImportStatus } from '$lib/csv-import-status-api';
import { totalSkippedFromCommit } from '$lib/csv-import-commit-metrics';
import { countCsvDataRows } from '$lib/csv-upload-row-count';
import CsvPendingImportsSheet from '$lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte';
import { csvFmt, csvMsg } from '$lib/i18n/csv-msg';
/** Intenta extraer un objeto tipo scan desde string tipo repr de Python. */
function parsePythonReprScan(s: string): Record<string, unknown> | null {
const jobIdMatch = s.match(/'job_id':\s*'([^']*)'/);
const totalRowsMatch = s.match(/'total_rows':\s*(\d+)/);
if (!jobIdMatch || !totalRowsMatch) return null;
const job_id = jobIdMatch[1];
const total_rows = parseInt(totalRowsMatch[1], 10);
const errorCountMatch = s.match(/'error_count':\s*(\d+)/);
const validRowsMatch = s.match(/'valid_rows':\s*(\d+)/);
const error_count = errorCountMatch ? parseInt(errorCountMatch[1], 10) : 0;
const valid_rows = validRowsMatch ? parseInt(validRowsMatch[1], 10) : 0;
const errors: { line: number; col: string; msg: string; solution?: string; warning?: boolean }[] = [];
// Buscar cada diccionario de error en la cadena.
// Nota: el backend suele serializar dicts con claves extra (p.ej. 'solution', 'warning'),
// así que el regex no puede asumir que el dict termina justo después de 'msg'.
const errRegex =
/\{'line':\s*(\d+),\s*'col':\s*'([^']*)',\s*'msg':\s*'((?:[^'\\]|\\.)*)'(?:,\s*'solution':\s*'((?:[^'\\]|\\.)*)')?(?:,\s*'warning':\s*(True|False))?[^}]*\}/g;
let m: RegExpExecArray | null;
while ((m = errRegex.exec(s)) !== null) {
errors.push({
line: parseInt(m[1], 10),
col: m[2],
msg: m[3].replace(/\\'/g, "'"),
solution: m[4]?.replace(/\\'/g, "'") ?? '',
warning: m[5] ? m[5] === 'True' : false
});
}
return {
status: 'waiting_confirmation',
job_id,
total_rows,
error_count,
valid_rows,
errors
};
}
/** Intenta extraer un resultado tipo commit (finished/warning) desde repr Python. */
function parsePythonReprCommit(s: string): Record<string, unknown> | null {
if (!s || typeof s !== 'string') return null;
const unescapePy = (v: string) =>
v.replace(/\\'/g, "'").replace(/\\\\n/g, '\n').replace(/\\\\t/g, '\t').replace(/\\\\r/g, '\r');
const safeInt = (value: unknown): number => {
if (value === null || value === undefined) return 0;
const raw = String(value).replace(/['"]/g, '').trim();
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : 0;
};
// Acepta tanto repr Python como JSON (comillas simples/dobles).
const statusMatch =
s.match(/['"]status['"]:\s*['"]([^'"]+)['"]/) || s.match(/['"]status['"]:\s*([^,}]+)/);
if (!statusMatch) return null;
const status = String(statusMatch[1] ?? '').replace(/['"]/g, '').trim();
if (status !== 'finished' && status !== 'warning') return null;
const insertedMatch = s.match(/['"]inserted['"]:\s*['"]?(-?\d+)['"]?/) || s.match(/['"]inserted['"]:\s*([^,}]+)/);
const inserted = insertedMatch ? safeInt(insertedMatch[1]) : 0;
const updatedMatch = s.match(/['"]updated['"]:\s*['"]?(-?\d+)['"]?/) || s.match(/['"]updated['"]:\s*([^,}]+)/);
const updated = updatedMatch ? safeInt(updatedMatch[1]) : 0;
const skippedInvalidMatch =
s.match(/['"]skipped_invalid['"]:\s*['"]?(\d+)['"]?/) || s.match(/['"]skipped_invalid['"]:\s*([^,}]+)/);
const skippedMissingFkMatch =
s.match(/['"]skipped_missing_fk['"]:\s*['"]?(\d+)['"]?/) || s.match(/['"]skipped_missing_fk['"]:\s*([^,}]+)/);
const skippedDuplicateMatch =
s.match(/['"]skipped_duplicate['"]:\s*['"]?(\d+)['"]?/) || s.match(/['"]skipped_duplicate['"]:\s*([^,}]+)/);
const skipped_invalid = skippedInvalidMatch ? safeInt(skippedInvalidMatch[1]) : 0;
const skipped_missing_fk = skippedMissingFkMatch
? safeInt(skippedMissingFkMatch[1])
: 0;
const skipped_duplicate = skippedDuplicateMatch
? safeInt(skippedDuplicateMatch[1])
: 0;
const skippedMissingInvMatch =
s.match(/['"]skipped_missing_invoice['"]:\s*['"]?(\d+)['"]?/) ||
s.match(/['"]skipped_missing_invoice['"]:\s*([^,}]+)/);
const skipped_missing_invoice = skippedMissingInvMatch
? safeInt(skippedMissingInvMatch[1])
: 0;
const gapsMatch =
s.match(/['"]critical_reference_gaps['"]:\s*['"]?(\d+)['"]?/) ||
s.match(/['"]critical_reference_gaps['"]:\s*([^,}]+)/);
const critical_reference_gaps = gapsMatch ? safeInt(gapsMatch[1]) : 0;
const refReadyMatch = s.match(/['"]reference_state_ready['"]:\s*(True|False|true|false)/);
let reference_state_ready = true;
if (refReadyMatch) {
reference_state_ready = refReadyMatch[1].toLowerCase() === 'true';
}
// Detalle opcional: lista de dicts con {line, reason, solution?}
const skipped_details: Array<{ line: number; reason: string; solution?: string }> = [];
const detailRegex =
/\{'line':\s*(\d+),\s*'reason':\s*'((?:[^'\\]|\\.)*)'(?:,\s*'solution':\s*'((?:[^'\\]|\\.)*)')?[^}]*\}/g;
let m: RegExpExecArray | null;
while ((m = detailRegex.exec(s)) !== null) {
skipped_details.push({
line: parseInt(m[1], 10),
reason: unescapePy(m[2]),
solution: m[3] ? unescapePy(m[3]) : undefined
});
}
const messageMatch =
s.match(/'message':\s*'((?:[^'\\]|\\.)*)'/) ||
s.match(/"message":\s*"((?:[^"\\]|\\.)*)"/);
const message = messageMatch ? unescapePy(messageMatch[1]) : undefined;
// Si no hay contadores en el texto, no es un commit.
if (
inserted === 0 &&
updated === 0 &&
skipped_invalid === 0 &&
skipped_missing_fk === 0 &&
skipped_duplicate === 0 &&
skipped_missing_invoice === 0 &&
critical_reference_gaps === 0
) {
return null;
}
// Mínimo requerido por el modal (contrato tipo pedimentos)
return {
status,
inserted,
updated,
skipped_invalid,
skipped_missing_fk,
skipped_duplicate,
skipped_missing_invoice,
critical_reference_gaps,
reference_state_ready,
skipped_details,
...(message ? { message } : {})
};
}
// We no longer need modal state
let activeTab = $state('catalogos');
let isUploading = $state(false);
let currentJobId = $state<string | null>(null);
let activeModelTarget = $state<string | null>(null);
let scanResults = $state<any>(null);
let commitResults = $state<any>(null);
let showResultModal = $state(false);
// Cuando es true, usamos API de importación de Agentes Aduanales (customs_brokers/imports)
let useCustomsBrokerImport = $state(false);
// Cuando es true, usamos API de importación de Clientes y Proveedores (clients_and_providers/imports)
let useClientProviderImport = $state(false);
// Cuando es true, usamos API de importación de Tipos de Cambio (exchange_rate/imports)
let useExchangeRateImport = $state(false);
// Cuando es true, usamos API de importación de Pedimentos (pedimentos/imports)
let usePedimentosImport = $state(false);
// Cuando es true, usamos API de importación de Clases de Materiales (classes/imports)
let useMaterialClassesImport = $state(false);
// Cuando es true, usamos API de importación de Vehículos / Transportes (vehicles/imports)
let useVehicleImport = $state(false);
// Cuando es true, usamos API de importación de Conductores (drivers/imports)
let useDriverImport = $state(false);
// Cuando es true, usamos API de importación de Trailers y Cajas (trailers/imports)
let useTrailerImport = $state(false);
// Cuando es true, usamos API de importación de Transportistas (transporters/imports)
let useTransporterImport = $state(false);
// Cuando es true, usamos API de importación de Números de parte (parts/imports)
let usePartNumbersImport = $state(false);
// Cuando es true, usamos API de importación de BOMs (boms/imports)
let useBomImport = $state(false);
// Cuando es true, usamos API de importación de Exportación (layouts_csv/exportacion)
let useExportacionImport = $state(false);
type CsvProgressPhase = 'idle' | 'upload' | 'scan' | 'commit';
let csvProgressPhase = $state<CsvProgressPhase>('idle');
let uploadProgressPct = $state(0);
/** true cuando el navegador reporta tamaño total en XHR (lengthComputable) */
let uploadLengthComputable = $state(false);
let scanProgressCurrent = $state(0);
let scanProgressTotal = $state(0);
let currentImportLabel = $state<string | null>(null);
let csvResumeOverlayHint = $state(false);
let skipScanCompleteToastOnce = $state(false);
/** Job id del escaneo listo para confirmar (para quitar de pendientes al hacer commit). */
let scanPhaseJobId = $state<string | null>(null);
/**
* Total de filas de datos conocido para la barra (máx. entre conteo local, total del poll Celery y total_rows del escaneo).
* No se reinicia mientras el modal de resultados siga abierto, para poder usar el mismo total en el commit.
*/
let csvImportRowTotal = $state(0);
const csvProgressStepTitle = $derived.by(() => {
switch (csvProgressPhase) {
case 'upload':
return csvMsg('progress.upload');
case 'scan':
return csvMsg('progress.scan');
case 'commit':
return csvMsg('progress.commit');
default:
return '';
}
});
/** Denominador único: filas de la tarea (registros del CSV / total reportado por el worker). */
const csvImportProgressDenom = $derived.by(() => {
const d = Math.max(csvImportRowTotal || 0, scanProgressTotal || 0);
return d > 0 ? d : 1;
});
const csvProgressDetailLine = $derived.by(() => {
const denom = csvImportProgressDenom;
switch (csvProgressPhase) {
case 'upload':
if (csvImportRowTotal > 0) {
return csvFmt('progress.rows_file', { n: csvImportRowTotal });
}
return uploadLengthComputable
? csvMsg('progress.upload_known')
: csvMsg('progress.upload_unknown');
case 'scan':
return csvFmt('progress.rows_scan', {
current: Math.min(scanProgressCurrent, denom),
total: denom
});
case 'commit':
return scanProgressTotal > 0
? csvFmt('progress.rows_commit', {
current: Math.min(scanProgressCurrent, denom),
total: denom
})
: csvFmt('progress.rows_commit_fallback', {
current: Math.min(scanProgressCurrent, denom),
total: denom
});
default:
return '';
}
});
/** Porcentaje según filas procesadas / total de filas (sin tramos artificiales por etapa). */
const csvCombinedProgressPct = $derived.by(() => {
if (!isUploading || csvProgressPhase === 'idle') return 0;
if (csvProgressPhase === 'upload') {
return 0;
}
const denom = csvImportProgressDenom;
const numer = Math.min(Math.max(0, scanProgressCurrent), denom);
return Math.min(100, Math.round((100 * numer) / denom));
});
/**
* Commit: muchas tareas no publican PROGRESS por fila; si current sigue en 0, barra indeterminada.
* Cuando Celery envía current &gt; 0, la barra pasa a reflejar registros reales.
*/
const csvFooterProgressIndeterminate = $derived(
isUploading && !showResultModal && csvProgressPhase === 'commit' && scanProgressCurrent <= 0
);
const csvFooterPercentLabel = $derived(
csvFooterProgressIndeterminate
? null
: csvProgressPhase === 'upload'
? '0%'
: `${csvCombinedProgressPct}%`
);
const csvFooterAriaValueText = $derived(
csvFooterProgressIndeterminate
? `${csvProgressStepTitle}. ${csvProgressDetailLine || csvMsg('progress.in_progress')}`
: csvProgressDetailLine
? `${csvProgressStepTitle}, ${csvFooterPercentLabel}. ${csvProgressDetailLine}`
: `${csvProgressStepTitle}, ${csvFooterPercentLabel}`
);
$effect(() => {
if (!isUploading) {
csvProgressPhase = 'idle';
uploadProgressPct = 0;
uploadLengthComputable = false;
// Con modal abierto (escaneo listo o resultado de commit) conservar totales para la siguiente fase o la UI.
if (!showResultModal) {
scanProgressCurrent = 0;
scanProgressTotal = 0;
csvImportRowTotal = 0;
}
currentImportLabel = null;
csvResumeOverlayHint = false;
scanPhaseJobId = null;
}
});
function resetAllImportFlags() {
useCustomsBrokerImport = false;
useClientProviderImport = false;
useExchangeRateImport = false;
usePedimentosImport = false;
useMaterialClassesImport = false;
useVehicleImport = false;
useDriverImport = false;
useTrailerImport = false;
useTransporterImport = false;
usePartNumbersImport = false;
useBomImport = false;
useExportacionImport = false;
}
function applyImportProfileFromSession(session: CsvImportSessionV1) {
resetAllImportFlags();
switch (session.profile) {
case 'customs_brokers':
useCustomsBrokerImport = true;
break;
case 'clients_providers':
useClientProviderImport = true;
break;
case 'exchange_rates':
useExchangeRateImport = true;
break;
case 'pedimentos':
usePedimentosImport = true;
break;
case 'material_classes':
useMaterialClassesImport = true;
break;
case 'vehicles':
useVehicleImport = true;
break;
case 'drivers':
useDriverImport = true;
break;
case 'trailers':
useTrailerImport = true;
break;
case 'transporters':
useTransporterImport = true;
break;
case 'part_numbers':
usePartNumbersImport = true;
break;
case 'boms':
useBomImport = true;
break;
case 'exportacion':
useExportacionImport = true;
break;
default:
break;
}
activeTab = session.activeTab;
currentImportLabel = session.label ?? null;
}
function resolveProfileFromFlags(): CsvImportProfile | null {
if (useCustomsBrokerImport) return 'customs_brokers';
if (useClientProviderImport) return 'clients_providers';
if (useExchangeRateImport) return 'exchange_rates';
if (usePedimentosImport) return 'pedimentos';
if (useMaterialClassesImport) return 'material_classes';
if (useVehicleImport) return 'vehicles';
if (useDriverImport) return 'drivers';
if (useTrailerImport) return 'trailers';
if (useTransporterImport) return 'transporters';
if (usePartNumbersImport) return 'part_numbers';
if (useBomImport) return 'boms';
if (useExportacionImport) return 'exportacion';
return 'imports';
}
function pushScanToPendingLocal(scanData: Record<string, unknown>) {
const cid = companyStore.activeCompany?.id;
if (cid === undefined) return;
const jid =
typeof scanData.job_id === 'string' && scanData.job_id
? scanData.job_id
: currentJobId
? currentJobId
: null;
if (!jid) return;
scanPhaseJobId = jid;
upsertCsvImportPending({
companyId: cid,
jobId: jid,
profile: resolveProfileFromFlags() ?? 'imports',
activeModelTarget,
activeTab: activeTab as CsvImportSessionV1['activeTab'],
label: currentImportLabel ?? undefined,
totalRows: typeof scanData.total_rows === 'number' ? scanData.total_rows : undefined,
validRows: typeof scanData.valid_rows === 'number' ? scanData.valid_rows : undefined
});
}
function finalizeCommitAndClearPending() {
if (scanPhaseJobId) {
removeCsvImportPending(scanPhaseJobId);
scanPhaseJobId = null;
}
}
/** Quita pendientes ligados al flujo actual (escaneo y/o commit pueden tener distinto job id). */
function removePendingLinkedToCurrentFlow() {
const ids = new Set<string>();
if (currentJobId) ids.add(currentJobId);
if (scanPhaseJobId) ids.add(scanPhaseJobId);
for (const id of ids) removeCsvImportPending(id);
}
function resumePendingImport(entry: CsvImportPendingEntry, scanPayload: Record<string, unknown>) {
const session: CsvImportSessionV1 = {
v: 1,
companyId: entry.companyId,
jobId: entry.jobId,
profile: entry.profile,
activeModelTarget: entry.activeModelTarget,
activeTab: entry.activeTab,
...(entry.label ? { label: entry.label } : {})
};
applyImportProfileFromSession(session);
activeModelTarget = entry.activeModelTarget;
activeTab = entry.activeTab;
currentJobId = entry.jobId;
currentImportLabel = entry.label ?? null;
scanPhaseJobId = entry.jobId;
scanResults = scanPayload;
commitResults = null;
showResultModal = true;
isUploading = false;
saveCsvImportSession(session);
skipScanCompleteToastOnce = true;
}
function persistCsvJobFromState() {
if (!browser || !currentJobId) return;
const profile = resolveProfileFromFlags() ?? 'imports';
const companyId = companyStore.activeCompany?.id;
if (companyId === undefined) return;
const payload: CsvImportSessionV1 = {
v: 1,
companyId,
jobId: currentJobId,
profile,
activeModelTarget,
activeTab: activeTab as CsvImportSessionV1['activeTab'],
...(currentImportLabel ? { label: currentImportLabel } : {})
};
saveCsvImportSession(payload);
}
function beginCsvScanAfterUpload() {
persistCsvJobFromState();
csvProgressPhase = 'scan';
uploadProgressPct = 100;
pollStatus();
}
let csvSessionRestoreAttempted = $state(false);
$effect(() => {
if (!browser) return;
const cid = companyStore.activeCompany?.id;
if (cid === undefined) return;
try {
const raw = sessionStorage.getItem(CSV_IMPORT_SESSION_KEY);
if (!raw) return;
const p = JSON.parse(raw) as { companyId?: number };
if (p.companyId != null && p.companyId !== cid) clearCsvImportSession();
} catch {
//
}
});
$effect(() => {
if (!browser || csvSessionRestoreAttempted) return;
const cid = companyStore.activeCompany?.id;
if (cid === undefined) return;
csvSessionRestoreAttempted = true;
const session = readCsvImportSession(cid);
if (!session) return;
applyImportProfileFromSession(session);
activeModelTarget = session.activeModelTarget;
activeTab = session.activeTab;
currentJobId = session.jobId;
currentImportLabel = session.label ?? null;
isUploading = true;
csvProgressPhase = 'scan';
uploadProgressPct = 100;
uploadLengthComputable = true;
csvResumeOverlayHint = true;
skipScanCompleteToastOnce = true;
queueMicrotask(() => void pollStatus());
});
onMount(() => {
const onVis = () => {
if (!browser || document.visibilityState !== 'visible') return;
if (currentJobId) void pollStatus();
};
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
});
// Initialize settings for all tabs upfront to avoid reactivity loops (sync init so child never receives undefined)
const _initialSettings: Record<string, any> = {};
for (const tab in tabSettings) {
_initialSettings[tab] = {};
tabSettings[tab].forEach((f) => {
_initialSettings[tab][f.name] = f.defaultValue;
});
}
let allSettings = $state<Record<string, any>>(_initialSettings);
// Global parameters for all CSV loads (merged with tab settings when sending footer_config)
const _initialGlobal: Record<string, string> = {};
globalCsvParams.forEach((p) => {
_initialGlobal[p.name] = String(p.defaultValue);
});
let globalSettings = $state<Record<string, string>>(_initialGlobal);
/** Coerce string 'true'/'false' to boolean for API payload. */
function coerceFooterConfig(obj: Record<string, any>): Record<string, any> {
const booleanKeys = [
'autonumber_series',
'load_subpartidas',
'recalculate_pedimento_date',
'autonumber_remesas',
'recalculate_dates',
'is_regime_change'
];
const out = { ...obj };
for (const k of booleanKeys) {
if (k in out && typeof out[k] === 'string') {
out[k] = out[k] === 'true';
}
}
return out;
}
async function handleUpload(file: File, config: CsvUploadItem) {
console.log('handleUpload started', { file, config });
isUploading = true;
csvProgressPhase = 'upload';
uploadProgressPct = 0;
uploadLengthComputable = false;
scanProgressCurrent = 0;
scanProgressTotal = 0;
csvImportRowTotal = 0;
try {
csvImportRowTotal = await countCsvDataRows(file);
} catch (e) {
console.warn('csv-upload: no se pudo contar filas del archivo', e);
}
csvResumeOverlayHint = false;
skipScanCompleteToastOnce = false;
scanPhaseJobId = null;
currentImportLabel = csvMsg(`items.${config.id}`);
const onCsvFileUploadProgress = (e: { loaded: number; total: number }) => {
if (e.total > 0) {
uploadLengthComputable = true;
uploadProgressPct = Math.min(100, Math.round((e.loaded / e.total) * 100));
}
};
activeModelTarget = config.modelTarget || null;
scanResults = null;
useCustomsBrokerImport = config.id === 'customs_brokers';
useClientProviderImport = config.id === 'clients_providers';
useExchangeRateImport = config.id === 'exchange_rates';
usePedimentosImport = config.id === 'pedimentos';
useMaterialClassesImport = config.id === 'material_classes';
useVehicleImport = config.id === 'transports';
useDriverImport = config.id === 'drivers';
useTrailerImport = config.id === 'trailers';
useTransporterImport = config.id === 'transporters';
usePartNumbersImport = config.id === 'part_numbers';
useBomImport = config.id === 'boms';
useExportacionImport = activeTab === 'exportacion';
const companyId = companyStore.activeCompany?.id || 1;
if (useCustomsBrokerImport) {
try {
const res = await api.customsBrokerImports.upload(file, companyId, {
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
}
if (useClientProviderImport) {
try {
const res = await api.clientProviderImports.upload(file, companyId, {
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
}
if (useExchangeRateImport) {
try {
const catalogosSettings = allSettings['catalogos'] || {};
const globalMode = globalSettings['mode'] ?? catalogosSettings['mode'] ?? 'update';
const reemplazar_sin_preguntar = globalMode === 'replace';
// Formato activo del selector global (Formato de Fecha); mismo default que la barra
const dateFormat =
globalSettings['dateFormat'] ?? catalogosSettings['dateFormat'] ?? globalCsvParams.find((p) => p.name === 'dateFormat')?.defaultValue ?? 'dd/mm/yyyy';
const res = await api.exchangeRateImports.upload(file, companyId, {
reemplazar_sin_preguntar,
date_format: dateFormat
}, { onUploadProgress: onCsvFileUploadProgress });
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
}
if (usePedimentosImport) {
try {
const catalogosSettings = allSettings['catalogos'] || {};
const actualizar = catalogosSettings['mode'] === 'update';
const dateFormat =
globalSettings['dateFormat'] ?? catalogosSettings['dateFormat'] ?? globalCsvParams.find((p) => p.name === 'dateFormat')?.defaultValue ?? 'dd/mm/yyyy';
const res = await api.pedimentosImports.upload(file, companyId, {
actualizar,
dateFormat
}, { onUploadProgress: onCsvFileUploadProgress });
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
}
if (useMaterialClassesImport) {
try {
const catalogosSettings = allSettings['catalogos'] || {};
const actualizar = catalogosSettings['mode'] === 'update';
const res = await api.materialClassImports.upload(file, companyId, {
actualizar,
siempre_toda: false
}, { onUploadProgress: onCsvFileUploadProgress });
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
}
if (useVehicleImport) {
try {
const catalogosSettings = allSettings['catalogos'] || {};
const actualizar = catalogosSettings['mode'] === 'update';
const res = await api.vehicleImports.upload(file, companyId, {
actualizar,
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
}
if (useDriverImport) {
try {
const res = await api.driverImports.upload(file, companyId, {
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
}
if (useTrailerImport) {
try {
const transportesSettings = allSettings['transportes'] || {};
const actualizar = transportesSettings['mode'] === 'update';
const res = await api.trailerImports.upload(file, companyId, {
actualizar,
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
}
if (useTransporterImport) {
try {
const transportesSettings = allSettings['transportes'] || {};
const actualizar = transportesSettings['mode'] === 'update';
const res = await api.transporterImports.upload(file, companyId, {
actualizar,
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
}
if (usePartNumbersImport) {
try {
const catalogosSettings = allSettings['catalogos'] || {};
const actualizar = catalogosSettings['mode'] === 'update';
const res = await api.partNumberImports.upload(file, companyId, {
actualizar,
reemplazar_sin_preguntar: true,
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
}
if (useBomImport) {
try {
const res = await api.bomImports.upload(file, companyId, {
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
}
const currentTabSettings = allSettings[activeTab] || {};
const footerConfig = coerceFooterConfig({
...globalSettings,
...currentTabSettings
});
if (activeTab === 'importacion') {
footerConfig.invoice_type = config.id?.startsWith('imp_def_') ? 'DEF' : 'TEM';
}
if (useExportacionImport) {
try {
const res = await api.exportacionImports.upload(
file,
config.modelTarget || '',
footerConfig,
companyId,
config.templateId || config.id,
{ onUploadProgress: onCsvFileUploadProgress }
);
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
}
const opType = activeTab === 'exportacion' ? 'exp' : 'imp';
try {
const res = await api.imports.upload(
file,
config.modelTarget || '',
footerConfig,
companyId,
opType,
config.templateId || config.id,
{ onUploadProgress: onCsvFileUploadProgress }
);
if (res.data?.job_id) {
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
}
function isStaleImportJobError(status: number, err: string | undefined): boolean {
if (status === 404) return true;
const m = (err || '').toLowerCase();
return /not found|no encontrado|404|expir|no disponible|invalid|inexistente/i.test(m);
}
async function pollStatus() {
if (!currentJobId) return;
try {
const profile = resolveProfileFromFlags() ?? 'imports';
const res = await fetchCsvImportStatus(currentJobId, profile);
console.log('Poll response', res);
csvResumeOverlayHint = false;
if (res.error && !res.data) {
const errMsg = res.error || '';
if (isStaleImportJobError(res.status, errMsg)) {
removePendingLinkedToCurrentFlow();
toast.info(csvMsg('toast.stale_job'));
} else {
toast.error(errMsg || csvMsg('toast.poll_err'));
}
isUploading = false;
clearCsvImportSession();
currentJobId = null;
return;
}
if (res.data?.status === 'processing') {
const p = (res.data as { progress?: unknown }).progress;
const t = (res.data as { total?: unknown }).total;
if (typeof p === 'number') scanProgressCurrent = p;
if (typeof t === 'number') {
scanProgressTotal = t;
csvImportRowTotal = Math.max(csvImportRowTotal, t);
}
}
// Tratar como resultado de escaneo si viene status waiting_confirmation O si el payload tiene forma de scan (job_id + total_rows)
const looksLikeScanResult =
res.data?.status === 'waiting_confirmation' ||
(res.data?.job_id && typeof res.data?.total_rows === 'number');
if (looksLikeScanResult) {
const tr = (res.data as { total_rows?: unknown }).total_rows;
if (typeof tr === 'number' && tr > 0) {
csvImportRowTotal = Math.max(csvImportRowTotal, tr);
}
scanResults = res.data;
pushScanToPendingLocal(res.data as Record<string, unknown>);
showResultModal = true;
if (skipScanCompleteToastOnce) {
skipScanCompleteToastOnce = false;
} else {
toast.success(csvMsg('toast.scan_done'));
}
isUploading = false;
} else if (res.data?.status === 'failed' || res.data?.status === 'FAILURE') {
const errRaw = res.data.error;
// Si el backend devolvió el resultado del scan dentro de error (p. ej. string JSON), usarlo para mostrar el modal
const errStringCandidate =
typeof errRaw === 'string'
? errRaw
: errRaw && typeof errRaw === 'object' && typeof (errRaw as any).message === 'string'
? (errRaw as any).message
: null;
const parsedCommit = errStringCandidate ? parsePythonReprCommit(errStringCandidate) : null;
if (parsedCommit) {
commitResults = parsedCommit;
showResultModal = true;
finalizeCommitAndClearPending();
toast.success(csvMsg('toast.import_done'));
isUploading = false;
clearCsvImportSession();
currentJobId = null;
return;
}
let parsedScan: Record<string, unknown> | null = null;
if (typeof errRaw === 'string' && (errRaw.includes('waiting_confirmation') || (errRaw.includes('total_rows') && errRaw.includes('job_id')))) {
try {
const parsed = JSON.parse(errRaw) as Record<string, unknown>;
if (parsed && typeof parsed.job_id === 'string' && typeof parsed.total_rows === 'number') {
parsedScan = parsed;
}
} catch {
// No es JSON; intentar parsear como repr de Python
parsedScan = parsePythonReprScan(errRaw);
}
}
if (parsedScan) {
const tr = parsedScan.total_rows;
if (typeof tr === 'number' && tr > 0) {
csvImportRowTotal = Math.max(csvImportRowTotal, tr);
}
scanResults = parsedScan;
pushScanToPendingLocal(parsedScan);
showResultModal = true;
if (skipScanCompleteToastOnce) {
skipScanCompleteToastOnce = false;
} else {
toast.success(csvMsg('toast.scan_done'));
}
isUploading = false;
} else {
// Mensaje parece resultado de escaneo pero no se pudo parsear (p. ej. repr Python) → no asustar con error
const looksLikeScanInError =
typeof errRaw === 'string' &&
(errRaw.includes('waiting_confirmation') || (errRaw.includes('total_rows') && errRaw.includes('job_id')));
if (looksLikeScanInError) {
removePendingLinkedToCurrentFlow();
toast.info(csvMsg('toast.scan_alt'));
isUploading = false;
clearCsvImportSession();
currentJobId = null;
return;
}
let errText: string;
const maybeDoneMsg = csvMsg('toast.import_maybe_done');
if (typeof errRaw === 'string') {
errText =
errRaw.includes('finished') && errRaw.includes('inserted')
? maybeDoneMsg
: errRaw.includes("'status'") && errRaw.includes('waiting_confirmation')
? csvMsg('toast.err_fetch_scan_result')
: errRaw;
} else if (typeof errRaw === 'object' && errRaw !== null) {
errText =
(errRaw as { message?: string })?.message || csvMsg('toast.err_processing_fallback');
} else {
errText = csvMsg('toast.err_unknown');
}
// No mostrar como error si el mensaje indica éxito
if (errText === maybeDoneMsg || errText.includes(maybeDoneMsg)) {
toast.success(errText);
} else {
toast.error(csvFmt('toast.error_processing', { msg: errText }));
}
isUploading = false;
removePendingLinkedToCurrentFlow();
clearCsvImportSession();
currentJobId = null;
scanResults = null;
commitResults = null;
showResultModal = false;
}
} else if (res.data?.status === 'warning') {
// Caso cuando no se insertaron registros pero hay información de rechazo
commitResults = res.data;
showResultModal = true;
const inserted = res.data?.inserted || 0;
const updated = res.data?.updated || 0;
const totalSkipped = totalSkippedFromCommit(res.data as Record<string, unknown>);
const backendMessage = res.data?.message;
const totalOk = inserted + updated;
if (totalOk === 0) {
toast.error(
backendMessage ||
csvFmt('toast.commit_warning_none', { skipped: totalSkipped })
);
} else {
toast.warning(
backendMessage ||
csvFmt('toast.commit_warning_ok', {
inserted,
updated,
skipped: totalSkipped
})
);
}
finalizeCommitAndClearPending();
isUploading = false;
} else if (res.data?.status === 'finished') {
commitResults = res.data;
showResultModal = true;
const inserted = res.data?.inserted || 0;
const updated = res.data?.updated || 0;
const totalSkipped = totalSkippedFromCommit(res.data as Record<string, unknown>);
if (inserted > 0 || updated > 0) {
const parts: string[] = [];
if (inserted > 0) parts.push(csvFmt('toast.n_inserted', { n: inserted }));
if (updated > 0) parts.push(csvFmt('toast.n_updated', { n: updated }));
toast.success(csvFmt('toast.success_counts', { msg: parts.join(', ') }));
if (totalSkipped > 0) {
toast.warning(csvFmt('toast.warn_skipped', { n: totalSkipped }));
}
} else {
toast.error(csvMsg('toast.finished_none'));
}
finalizeCommitAndClearPending();
isUploading = false;
} else {
// Continue polling
console.log('Status not final, polling again in 2s...', res.data?.status);
setTimeout(pollStatus, 800);
}
} catch (e) {
console.error('Poll exception', e);
// Retry on network error? Or fail?
// For now, let's keep retrying a few times or hard fail.
// Let's just log and retry.
setTimeout(pollStatus, 800);
}
}
</script>
{#if !canProcessCsv}
<div class="flex h-[calc(100vh-4rem)] items-center justify-center">
<ErrorState status={403} />
</div>
{:else}
<div class="flex flex-col flex-1 min-h-0 -m-4 overflow-hidden">
<!-- Single scroll: content scrolls here; padding at bottom reserves space for fixed params bar -->
<div class="flex-1 min-h-0 overflow-y-auto p-4 md:p-8 space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-lg font-semibold md:text-2xl">{csvMsg('page_title')}</h1>
<CsvPendingImportsSheet companyId={companyStore.activeCompany?.id} onResume={resumePendingImport} />
</div>
<p class="text-sm text-muted-foreground">
{csvMsg('intro_help')}
</p>
<Tabs.Root bind:value={activeTab} class="w-full">
<Tabs.List class="grid w-full grid-cols-2 md:grid-cols-4 lg:w-auto">
<Tabs.Trigger value="catalogos">{csvMsg('tab_catalogos')}</Tabs.Trigger>
<Tabs.Trigger value="transportes">{csvMsg('tab_transportes')}</Tabs.Trigger>
<Tabs.Trigger value="importacion">{csvMsg('tab_importacion')}</Tabs.Trigger>
<Tabs.Trigger value="exportacion">{csvMsg('tab_exportacion')}</Tabs.Trigger>
</Tabs.List>
<div class="mt-6">
<Tabs.Content value="catalogos" class="space-y-4">
<!-- Catálogos: backend layouts_csv (customs_brokers, clients_and_providers, parts, boms, etc.) -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">{csvMsg('section_catalogs')}</h2>
</div>
<UploadLauncherGrid
items={catalogosConfig}
onUpload={handleUpload}
busy={isUploading}
/>
</Tabs.Content>
<Tabs.Content value="transportes" class="space-y-4">
<!-- Logística: backend layouts_csv (vehicles, drivers, trailers); transportistas sin layouts_csv -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">{csvMsg('section_transport')}</h2>
</div>
<UploadLauncherGrid
items={transportesConfig}
onUpload={handleUpload}
busy={isUploading}
/>
</Tabs.Content>
<Tabs.Content value="importacion" class="space-y-4">
<!-- Operaciones de Importación: backend layouts_csv/facturas (api.imports) -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">{csvMsg('section_import')}</h2>
</div>
<UploadLauncherGrid
items={importacionConfig}
onUpload={handleUpload}
busy={isUploading}
/>
</Tabs.Content>
<Tabs.Content value="exportacion" class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">{csvMsg('section_export')}</h2>
</div>
<UploadLauncherGrid
items={exportacionConfig}
onUpload={handleUpload}
busy={isUploading}
/>
</Tabs.Content>
</div>
</Tabs.Root>
<!-- Spacer: barra de parámetros + franja de progreso cuando aplica -->
<div
class="shrink-0"
style="height: calc(var(--csv-params-bar-height, 6rem) + {(isUploading && !showResultModal)
? '4.25rem'
: '0rem'});"
aria-hidden="true"
></div>
</div>
<!-- Pie fijo: progreso compacto + parámetros (sin overlay a pantalla completa) -->
<div
class="fixed right-0 bottom-0 left-0 z-40 ml-[calc(var(--sidebar-width))] flex flex-col border-t border-border bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
{#if isUploading && !showResultModal}
<div class="border-b border-border/70">
<div
class="mx-auto w-full max-w-[1400px] space-y-1.5 px-4 py-2"
role="status"
aria-live="polite"
aria-busy="true"
>
{#if csvResumeOverlayHint}
<p class="text-xs text-muted-foreground">{csvMsg('progress.resume_hint')}</p>
{/if}
{#if currentImportLabel}
<p class="text-xs font-medium text-foreground">{currentImportLabel}</p>
{/if}
<div class="flex items-start justify-between gap-3">
<p class="min-w-0 flex-1 text-left text-xs font-medium leading-snug text-foreground md:text-sm">
{csvProgressStepTitle}
</p>
{#if csvFooterPercentLabel}
<span class="shrink-0 tabular-nums text-xs font-semibold text-foreground md:text-sm" aria-hidden="true">
{csvFooterPercentLabel}
</span>
{/if}
</div>
{#if csvProgressDetailLine}
<p class="text-[11px] leading-snug text-muted-foreground md:text-xs">{csvProgressDetailLine}</p>
{/if}
<div class="w-full">
{#if csvFooterProgressIndeterminate}
<div class="relative h-1.5 w-full overflow-hidden rounded-full bg-muted/70">
<div
class="csv-upload-indeterminate-bar absolute top-0 h-full w-2/5 rounded-full bg-primary"
></div>
</div>
{:else}
<Progress
value={csvCombinedProgressPct}
max={100}
class="h-1.5 bg-muted/70"
aria-valuetext={csvFooterAriaValueText}
/>
{/if}
</div>
</div>
</div>
{/if}
<CsvParamsBar
embedded
bind:globalSettings
{activeTab}
bind:tabSettingsValues={allSettings[activeTab]}
/>
</div>
</div>
{#if scanResults || commitResults}
<ProcessingResultModal
bind:open={showResultModal}
{scanResults}
{commitResults}
{isUploading}
scanErrorsDownloadType={
useCustomsBrokerImport
? 'customs_brokers'
: useClientProviderImport
? 'clients_providers'
: useExchangeRateImport
? 'exchange_rate'
: usePedimentosImport
? 'pedimentos'
: useMaterialClassesImport
? 'classes'
: useVehicleImport
? 'vehicles'
: useDriverImport
? 'drivers'
: useTrailerImport
? 'trailers'
: useTransporterImport
? 'transporters'
: usePartNumbersImport
? 'parts'
: useBomImport
? 'boms'
: useExportacionImport
? 'exportacion'
: 'imports'
}
onConfirm={async () => {
if (!currentJobId) return;
try {
isUploading = true;
csvProgressPhase = 'commit';
scanProgressCurrent = 0;
const res = useCustomsBrokerImport
? await api.customsBrokerImports.commit(currentJobId)
: useClientProviderImport
? await api.clientProviderImports.commit(currentJobId)
: useExchangeRateImport
? await api.exchangeRateImports.commit(currentJobId)
: usePedimentosImport
? await api.pedimentosImports.commit(currentJobId)
: useMaterialClassesImport
? await api.materialClassImports.commit(currentJobId)
: useVehicleImport
? await api.vehicleImports.commit(currentJobId)
: useDriverImport
? await api.driverImports.commit(currentJobId)
: useTrailerImport
? await api.trailerImports.commit(currentJobId)
: useTransporterImport
? await api.transporterImports.commit(currentJobId)
: usePartNumbersImport
? await api.partNumberImports.commit(currentJobId)
: useBomImport
? await api.bomImports.commit(currentJobId)
: useExportacionImport
? await api.exportacionImports.commit(currentJobId, activeModelTarget || '')
: await api.imports.commit(currentJobId, activeModelTarget || '');
if (res.data?.commit_job_id) {
currentJobId = res.data.commit_job_id;
persistCsvJobFromState();
pollStatus();
}
} catch (err) {
toast.error(csvMsg('toast.commit_err'));
isUploading = false;
}
}}
onCancel={() => {
clearCsvImportSession();
currentJobId = null;
scanResults = null;
commitResults = null;
showResultModal = false;
}}
onClose={() => {
clearCsvImportSession();
currentJobId = null;
scanResults = null;
commitResults = null;
showResultModal = false;
}}
/>
{/if}
{/if}
<style>
@keyframes csv-upload-indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
.csv-upload-indeterminate-bar {
animation: csv-upload-indeterminate 1.2s ease-in-out infinite;
}
</style>