Merge branch 'feature/csv-cajas-trailers' into feature/faltantes-csv
This commit is contained in:
@@ -341,6 +341,173 @@ export const api = {
|
||||
validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}/`)
|
||||
},
|
||||
|
||||
imports: {
|
||||
upload: (
|
||||
file: File,
|
||||
modelTarget: string,
|
||||
footerConfig: any,
|
||||
companyId: number,
|
||||
operationType: string,
|
||||
templateId?: string
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (footerConfig) {
|
||||
formData.append('footer_config', JSON.stringify(footerConfig));
|
||||
}
|
||||
if (templateId) {
|
||||
formData.append('template_id', templateId);
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
company_id: String(companyId),
|
||||
operation_type: operationType || 'imp'
|
||||
}).toString();
|
||||
|
||||
return fetchApi(`/v1/a76/imports/upload/${modelTarget}?${queryParams}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/imports/${jobId}/status`),
|
||||
commit: (jobId: string, modelTarget: string) =>
|
||||
api.post(`/v1/a76/imports/${jobId}/commit`, { model_target: modelTarget })
|
||||
},
|
||||
|
||||
// CSV import for Agentes Aduanales (flujo propio en customs_brokers/imports)
|
||||
customsBrokerImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/customs-brokers/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/customs-brokers/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/customs-brokers/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Clientes y Proveedores (flujo propio en clients_and_providers/imports)
|
||||
clientProviderImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/clients-providers/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/clients-providers/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/clients-providers/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Tipos de Cambio (flujo en general_catalogs/exchange_rate/imports)
|
||||
exchangeRateImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/exchange-rate/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/exchange-rate/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/exchange-rate/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Fracción Americana (us_tariff_fractions/imports)
|
||||
americanFractionImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/us-tariff-fractions/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/us-tariff-fractions/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/us-tariff-fractions/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Pedimentos (pedimentos/imports)
|
||||
pedimentosImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/pedimentos/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/pedimentos/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/pedimentos/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Clases de Materiales (classes/imports)
|
||||
materialClassImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/classes/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/classes/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/classes/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Vehículos / Transportes (transportation/vehicles/imports)
|
||||
vehicleImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/transportation/vehicles/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/transportation/vehicles/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/transportation/vehicles/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Conductores (drivers/imports)
|
||||
driverImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/drivers/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/drivers/imports/${jobId}/status`),
|
||||
commit: (jobId: string) => api.post(`/v1/a76/drivers/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Trailers y Cajas (transportation/trailers/imports)
|
||||
trailerImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/transportation/trailers/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/transportation/trailers/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/transportation/trailers/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// Generic request for custom needs (like file uploads)
|
||||
request: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, options)
|
||||
};
|
||||
|
||||
@@ -143,17 +143,53 @@
|
||||
|
||||
{#if scanResults.error_count > 0}
|
||||
<div
|
||||
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3"
|
||||
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3 mb-4"
|
||||
>
|
||||
<XCircle class="w-5 h-5 text-destructive mt-0.5 shrink-0" />
|
||||
<div class="text-sm text-destructive-foreground/90">
|
||||
<p class="font-semibold mb-1">Se detectaron problemas en el archivo</p>
|
||||
<p>
|
||||
Las filas con errores serán omitidas automáticamente. Solo se importarán los
|
||||
registros válidos.
|
||||
Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para
|
||||
importar solo las filas válidas (las erróneas se omitirán).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if scanResults.errors && scanResults.errors.length > 0}
|
||||
<div class="border rounded-lg overflow-hidden shadow-sm">
|
||||
<div class="bg-muted/50 px-4 py-2 border-b flex justify-between items-center">
|
||||
<h5 class="text-xs font-bold text-foreground uppercase tracking-wide">
|
||||
Detalle de errores (para corregir en el CSV)
|
||||
</h5>
|
||||
<span
|
||||
class="text-[10px] bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border"
|
||||
>
|
||||
{scanResults.errors.length} error(es)
|
||||
</span>
|
||||
</div>
|
||||
<div class="max-h-60 overflow-y-auto bg-card relative">
|
||||
<table class="w-full text-xs text-left">
|
||||
<thead
|
||||
class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-4 py-2 w-16">Línea</th>
|
||||
<th class="px-4 py-2 w-40">Columna</th>
|
||||
<th class="px-4 py-2">Mensaje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
{#each scanResults.errors as err}
|
||||
<tr class="hover:bg-muted/30 transition-colors">
|
||||
<td class="px-4 py-2 font-mono text-muted-foreground">{err.line}</td>
|
||||
<td class="px-4 py-2 font-mono font-medium text-foreground">{err.col || '-'}</td>
|
||||
<td class="px-4 py-2 text-destructive">{err.msg || '-'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="rounded-md bg-primary/5 border border-primary/10 p-4 flex items-start gap-3">
|
||||
<CheckCircle2 class="w-5 h-5 text-primary mt-0.5 shrink-0" />
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
ondragover={(e) => handleDragOver(e, item.disabled)}
|
||||
ondrop={(e) => handleDrop(e, item)}
|
||||
oncontextmenu={(e) => handleContextMenu(e, item)}
|
||||
roles="button"
|
||||
role="button"
|
||||
tabindex={item.disabled ? -1 : 0}
|
||||
onclick={() => handleClick(item.id, item.disabled)}
|
||||
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}
|
||||
|
||||
@@ -8,6 +8,20 @@ export type { CustomsBroker };
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBroker>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
cell: ({ row }) => {
|
||||
const idSnippet = createRawSnippet<[{ id: number }]>((getId) => {
|
||||
const { id } = getId();
|
||||
return {
|
||||
render: () =>
|
||||
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm">${id}</code>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(idSnippet, { id: row.original.id });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "broker_key",
|
||||
header: "Clave",
|
||||
|
||||
@@ -249,7 +249,7 @@ export const importacionConfig: CsvUploadItem[] = [
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Impo. Def.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
modelTarget: 'invoice_header',
|
||||
templateUrl: '/csv/EstructuraEncFacImpoDef.xls'
|
||||
},
|
||||
{
|
||||
@@ -257,7 +257,7 @@ export const importacionConfig: CsvUploadItem[] = [
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Impo. Def.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
modelTarget: 'invoice_details',
|
||||
templateUrl: '/csv/EstructuraParFacImpoDefAF.xls'
|
||||
},
|
||||
{
|
||||
@@ -274,7 +274,7 @@ export const importacionConfig: CsvUploadItem[] = [
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Compras Mex.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
modelTarget: 'invoice_header',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
@@ -282,7 +282,7 @@ export const importacionConfig: CsvUploadItem[] = [
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Compras Mex.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
modelTarget: 'invoice_details',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
@@ -302,7 +302,7 @@ export const exportacionConfig: CsvUploadItem[] = [
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
modelTarget: 'invoice_header',
|
||||
templateUrl: '/csv/EstructuraEncFacExpoCamReg.xls'
|
||||
},
|
||||
{
|
||||
@@ -310,7 +310,7 @@ export const exportacionConfig: CsvUploadItem[] = [
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
modelTarget: 'invoice_details',
|
||||
templateUrl: '/csv/EstructuraParExpoCamReg.xls'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,209 +1,474 @@
|
||||
<script lang="ts">
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import UploadLauncherGrid from '$lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte';
|
||||
import ConfigFooter from '$lib/components/dashboard/csv-upload/ConfigFooter.svelte';
|
||||
import ProcessingResultModal from '$lib/components/dashboard/csv-upload/ProcessingResultModal.svelte';
|
||||
import {
|
||||
catalogosConfig,
|
||||
transportesConfig,
|
||||
importacionConfig,
|
||||
exportacionConfig,
|
||||
tabSettings,
|
||||
type CsvUploadItem
|
||||
} from '$lib/config/csv-upload';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// 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);
|
||||
|
||||
// Initialize settings for all tabs upfront to avoid reactivity loops
|
||||
let allSettings = $state<Record<string, any>>(() => {
|
||||
const initial: Record<string, any> = {};
|
||||
for (const tab in tabSettings) {
|
||||
initial[tab] = {};
|
||||
tabSettings[tab].forEach((f) => {
|
||||
initial[tab][f.name] = f.defaultValue;
|
||||
});
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
async function handleUpload(file: File, config: CsvUploadItem) {
|
||||
isUploading = true;
|
||||
activeModelTarget = config.modelTarget || null;
|
||||
scanResults = null;
|
||||
const currentSettings = allSettings[activeTab] || {};
|
||||
const companyId = companyStore.activeCompany?.id || 1;
|
||||
const opType = activeTab === 'exportacion' ? 'exp' : 'imp';
|
||||
|
||||
const res = await api.imports.upload(
|
||||
file,
|
||||
config.modelTarget || '',
|
||||
currentSettings,
|
||||
companyId,
|
||||
opType
|
||||
);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error('Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
if (!currentJobId) return;
|
||||
|
||||
const res = await api.imports.status(currentJobId);
|
||||
if (res.data?.status === 'waiting_confirmation') {
|
||||
scanResults = res.data;
|
||||
showResultModal = true;
|
||||
toast.success('Escaneo completado. Revisa los resultados.');
|
||||
isUploading = false;
|
||||
} else if (res.data?.status === 'failed') {
|
||||
toast.error('Error en el procesamiento: ' + (res.data.error || 'Error desconocido'));
|
||||
isUploading = false;
|
||||
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 skippedInvalid = res.data?.skipped_invalid || 0;
|
||||
const skippedFk = res.data?.skipped_missing_fk || 0;
|
||||
const totalSkipped = skippedInvalid + skippedFk;
|
||||
|
||||
if (inserted === 0) {
|
||||
toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`);
|
||||
} else {
|
||||
toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`);
|
||||
}
|
||||
isUploading = false;
|
||||
} else if (res.data?.status === 'finished') {
|
||||
commitResults = res.data;
|
||||
showResultModal = true;
|
||||
const inserted = res.data?.inserted || 0;
|
||||
const skippedInvalid = res.data?.skipped_invalid || 0;
|
||||
const skippedFk = res.data?.skipped_missing_fk || 0;
|
||||
const skippedDetails = res.data?.skipped_details || [];
|
||||
|
||||
if (inserted > 0) {
|
||||
toast.success(`Importación completada: ${inserted} registros insertados`);
|
||||
if (skippedInvalid > 0 || skippedFk > 0) {
|
||||
const totalSkipped = skippedInvalid + skippedFk;
|
||||
toast.warning(`${totalSkipped} registros fueron rechazados`);
|
||||
}
|
||||
} else {
|
||||
toast.error('No se insertaron registros. Revisa los errores a continuación.');
|
||||
}
|
||||
isUploading = false;
|
||||
} else {
|
||||
// Continue polling
|
||||
setTimeout(pollStatus, 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-[calc(100vh-4rem)] -m-4 overflow-hidden">
|
||||
<!-- Scrollable Content Area -->
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-lg font-semibold md:text-2xl">Importación Masiva de Datos (CSV)</h1>
|
||||
</div>
|
||||
|
||||
<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">Catálogos</Tabs.Trigger>
|
||||
<Tabs.Trigger value="transportes">Transportes</Tabs.Trigger>
|
||||
<Tabs.Trigger value="importacion">Importación</Tabs.Trigger>
|
||||
<Tabs.Trigger value="exportacion">Exportación</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<div class="mt-6">
|
||||
<Tabs.Content value="catalogos" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Catálogos Generales</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={catalogosConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="transportes" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Logística y Transporte</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={transportesConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="importacion" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Operaciones de Importación</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={importacionConfig} onUpload={handleUpload} />
|
||||
</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">Operaciones de Exportación</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={exportacionConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
<div class="h-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer Area -->
|
||||
{#if allSettings[activeTab]}
|
||||
<div class="flex-none z-20">
|
||||
<ConfigFooter {activeTab} bind:settings={allSettings[activeTab]} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ProcessingResultModal
|
||||
bind:open={showResultModal}
|
||||
{scanResults}
|
||||
{commitResults}
|
||||
{isUploading}
|
||||
onConfirm={async () => {
|
||||
if (currentJobId && activeModelTarget) {
|
||||
try {
|
||||
isUploading = true;
|
||||
const res = await api.imports.commit(currentJobId, activeModelTarget);
|
||||
if (res.data?.commit_job_id) {
|
||||
currentJobId = res.data.commit_job_id;
|
||||
pollStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al iniciar la importación');
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
}}
|
||||
onClose={() => {
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
}}
|
||||
/>
|
||||
<script lang="ts">
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import UploadLauncherGrid from '$lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte';
|
||||
import ConfigFooter from '$lib/components/dashboard/csv-upload/ConfigFooter.svelte';
|
||||
import ProcessingResultModal from '$lib/components/dashboard/csv-upload/ProcessingResultModal.svelte';
|
||||
import {
|
||||
catalogosConfig,
|
||||
transportesConfig,
|
||||
importacionConfig,
|
||||
exportacionConfig,
|
||||
tabSettings,
|
||||
type CsvUploadItem
|
||||
} from '$lib/config/csv-upload';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// 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 Fracción Americana (us_tariff_fractions/imports)
|
||||
let useAmericanFractionImport = $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);
|
||||
|
||||
// Initialize settings for all tabs upfront to avoid reactivity loops
|
||||
let allSettings = $state<Record<string, any>>(() => {
|
||||
const initial: Record<string, any> = {};
|
||||
for (const tab in tabSettings) {
|
||||
initial[tab] = {};
|
||||
tabSettings[tab].forEach((f) => {
|
||||
initial[tab][f.name] = f.defaultValue;
|
||||
});
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
async function handleUpload(file: File, config: CsvUploadItem) {
|
||||
console.log('handleUpload started', { file, config });
|
||||
isUploading = true;
|
||||
activeModelTarget = config.modelTarget || null;
|
||||
scanResults = null;
|
||||
useCustomsBrokerImport = config.id === 'customs_brokers';
|
||||
useClientProviderImport = config.id === 'clients_providers';
|
||||
useExchangeRateImport = config.id === 'exchange_rates';
|
||||
useAmericanFractionImport = config.id === 'american_fractions';
|
||||
usePedimentosImport = config.id === 'pedimentos';
|
||||
useMaterialClassesImport = config.id === 'material_classes';
|
||||
useVehicleImport = config.id === 'transports';
|
||||
useDriverImport = config.id === 'drivers';
|
||||
useTrailerImport = config.id === 'trailers';
|
||||
|
||||
const companyId = companyStore.activeCompany?.id || 1;
|
||||
|
||||
if (useCustomsBrokerImport) {
|
||||
try {
|
||||
const res = await api.customsBrokerImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useClientProviderImport) {
|
||||
try {
|
||||
const res = await api.clientProviderImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useExchangeRateImport) {
|
||||
try {
|
||||
const res = await api.exchangeRateImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useAmericanFractionImport) {
|
||||
try {
|
||||
const res = await api.americanFractionImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (usePedimentosImport) {
|
||||
try {
|
||||
const res = await api.pedimentosImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useMaterialClassesImport) {
|
||||
try {
|
||||
const res = await api.materialClassImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useVehicleImport) {
|
||||
try {
|
||||
const res = await api.vehicleImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useDriverImport) {
|
||||
try {
|
||||
const res = await api.driverImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useTrailerImport) {
|
||||
try {
|
||||
const res = await api.trailerImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSettings = allSettings[activeTab] || {};
|
||||
const footerConfig = { ...currentSettings };
|
||||
if (activeTab === 'importacion') {
|
||||
footerConfig.invoice_type = config.id?.startsWith('imp_def_') ? 'DEF' : 'TEM';
|
||||
}
|
||||
const opType = activeTab === 'exportacion' ? 'exp' : 'imp';
|
||||
|
||||
try {
|
||||
const res = await api.imports.upload(
|
||||
file,
|
||||
config.modelTarget || '',
|
||||
footerConfig,
|
||||
companyId,
|
||||
opType,
|
||||
config.id
|
||||
);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
if (!currentJobId) return;
|
||||
|
||||
try {
|
||||
const res = useCustomsBrokerImport
|
||||
? await api.customsBrokerImports.status(currentJobId)
|
||||
: useClientProviderImport
|
||||
? await api.clientProviderImports.status(currentJobId)
|
||||
: useExchangeRateImport
|
||||
? await api.exchangeRateImports.status(currentJobId)
|
||||
: useAmericanFractionImport
|
||||
? await api.americanFractionImports.status(currentJobId)
|
||||
: usePedimentosImport
|
||||
? await api.pedimentosImports.status(currentJobId)
|
||||
: useMaterialClassesImport
|
||||
? await api.materialClassImports.status(currentJobId)
|
||||
: useVehicleImport
|
||||
? await api.vehicleImports.status(currentJobId)
|
||||
: useDriverImport
|
||||
? await api.driverImports.status(currentJobId)
|
||||
: useTrailerImport
|
||||
? await api.trailerImports.status(currentJobId)
|
||||
: await api.imports.status(currentJobId);
|
||||
console.log('Poll response', res);
|
||||
if (res.error && !res.data) {
|
||||
toast.error(res.error || 'Error al consultar el estado');
|
||||
isUploading = false;
|
||||
currentJobId = null;
|
||||
return;
|
||||
}
|
||||
if (res.data?.status === 'waiting_confirmation') {
|
||||
scanResults = res.data;
|
||||
showResultModal = true;
|
||||
toast.success('Escaneo completado. Revisa los resultados.');
|
||||
isUploading = false;
|
||||
} else if (res.data?.status === 'failed' || res.data?.status === 'FAILURE') {
|
||||
const errRaw = res.data.error;
|
||||
const errText =
|
||||
typeof errRaw === 'string'
|
||||
? errRaw.includes('finished') && errRaw.includes('inserted')
|
||||
? 'La importación pudo completarse. Revisa el listado de registros.'
|
||||
: errRaw
|
||||
: (errRaw?.message ?? 'Error desconocido');
|
||||
toast.error('Error en el procesamiento: ' + errText);
|
||||
isUploading = false;
|
||||
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 skippedInvalid = res.data?.skipped_invalid || 0;
|
||||
const skippedFk = res.data?.skipped_missing_fk || 0;
|
||||
const skippedDup = res.data?.skipped_duplicate || 0;
|
||||
const totalSkipped = skippedInvalid + skippedFk + skippedDup;
|
||||
|
||||
if (inserted === 0) {
|
||||
toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`);
|
||||
} else {
|
||||
toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`);
|
||||
}
|
||||
isUploading = false;
|
||||
} else if (res.data?.status === 'finished') {
|
||||
commitResults = res.data;
|
||||
showResultModal = true;
|
||||
const inserted = res.data?.inserted || 0;
|
||||
const skippedInvalid = res.data?.skipped_invalid || 0;
|
||||
const skippedFk = res.data?.skipped_missing_fk || 0;
|
||||
const skippedDup = res.data?.skipped_duplicate || 0;
|
||||
const skippedDetails = res.data?.skipped_details || [];
|
||||
|
||||
if (inserted > 0) {
|
||||
toast.success(`Importación completada: ${inserted} registros insertados`);
|
||||
if (skippedInvalid > 0 || skippedFk > 0 || skippedDup > 0) {
|
||||
const totalSkipped = skippedInvalid + skippedFk + skippedDup;
|
||||
toast.warning(`${totalSkipped} registros fueron rechazados`);
|
||||
}
|
||||
} else {
|
||||
toast.error('No se insertaron registros. Revisa los errores a continuación.');
|
||||
}
|
||||
isUploading = false;
|
||||
} else {
|
||||
// Continue polling
|
||||
console.log('Status not final, polling again in 2s...', res.data?.status);
|
||||
setTimeout(pollStatus, 2000);
|
||||
}
|
||||
} 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, 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-[calc(100vh-4rem)] -m-4 overflow-hidden">
|
||||
<!-- Scrollable Content Area -->
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-lg font-semibold md:text-2xl">Importación Masiva de Datos (CSV)</h1>
|
||||
</div>
|
||||
|
||||
<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">Catálogos</Tabs.Trigger>
|
||||
<Tabs.Trigger value="transportes">Transportes</Tabs.Trigger>
|
||||
<Tabs.Trigger value="importacion">Importación</Tabs.Trigger>
|
||||
<Tabs.Trigger value="exportacion">Exportación</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<div class="mt-6">
|
||||
<Tabs.Content value="catalogos" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Catálogos Generales</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={catalogosConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="transportes" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Logística y Transporte</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={transportesConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="importacion" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Operaciones de Importación</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={importacionConfig} onUpload={handleUpload} />
|
||||
</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">Operaciones de Exportación</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={exportacionConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
<div class="h-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer Area -->
|
||||
{#if allSettings[activeTab]}
|
||||
<div class="flex-none z-20">
|
||||
<ConfigFooter {activeTab} bind:settings={allSettings[activeTab]} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if scanResults || commitResults}
|
||||
<ProcessingResultModal
|
||||
bind:open={showResultModal}
|
||||
{scanResults}
|
||||
{commitResults}
|
||||
{isUploading}
|
||||
onConfirm={async () => {
|
||||
if (!currentJobId) return;
|
||||
try {
|
||||
isUploading = true;
|
||||
const res = useCustomsBrokerImport
|
||||
? await api.customsBrokerImports.commit(currentJobId)
|
||||
: useClientProviderImport
|
||||
? await api.clientProviderImports.commit(currentJobId)
|
||||
: useExchangeRateImport
|
||||
? await api.exchangeRateImports.commit(currentJobId)
|
||||
: useAmericanFractionImport
|
||||
? await api.americanFractionImports.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)
|
||||
: await api.imports.commit(currentJobId, activeModelTarget || '');
|
||||
if (res.data?.commit_job_id) {
|
||||
currentJobId = res.data.commit_job_id;
|
||||
pollStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al iniciar la importación');
|
||||
isUploading = false;
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
}}
|
||||
onClose={() => {
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user