1393 lines
46 KiB
Svelte
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 > 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>
|