feature/csv-envoices-integration

This commit is contained in:
hreyes
2026-02-16 17:17:07 -06:00
parent eab10e6fbc
commit 91c3f7105b
14 changed files with 1777 additions and 362 deletions

View File

@@ -0,0 +1,275 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import {
Loader2,
CheckCircle2,
AlertTriangle,
XCircle,
FileText,
UploadCloud
} from 'lucide-svelte';
let {
open = $bindable(false),
scanResults = null,
commitResults = null,
isUploading = false,
onConfirm,
onCancel,
onClose
}: {
open: boolean;
scanResults: any;
commitResults: any;
isUploading: boolean;
onConfirm: () => void;
onCancel: () => void;
onClose: () => void;
} = $props();
// Determine state
let isPending = $derived(!!scanResults && !commitResults);
let isFinished = $derived(!!commitResults);
// Derived metrics for UI logic
let hasErrors = $derived(
scanResults?.error_count > 0 ||
commitResults?.skipped_invalid > 0 ||
commitResults?.skipped_missing_fk > 0
);
let totalSkipped = $derived(
(commitResults?.skipped_invalid || 0) +
(commitResults?.skipped_missing_fk || 0) +
(commitResults?.skipped_missing_invoice || 0)
);
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
if (isPending) onCancel();
else onClose();
}
open = newOpen;
}
</script>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Content
class="sm:max-w-[650px] p-0 gap-0 overflow-hidden border-0 shadow-xl flex flex-col max-h-[90vh] bg-background text-foreground"
>
<!-- Header Section with Theme Colors -->
<div
class="px-6 py-5 border-b flex items-start gap-4
{isPending
? 'bg-primary/5'
: isFinished && !hasErrors
? 'bg-green-50/50 dark:bg-green-900/10'
: 'bg-destructive/5'}"
>
<div
class="p-2 rounded-full ring-1 ring-inset
{isPending
? 'bg-primary/10 text-primary ring-primary/20'
: isFinished && !hasErrors
? 'bg-green-100 text-green-700 ring-green-200 dark:bg-green-900/20 dark:text-green-400 dark:ring-green-900/40'
: 'bg-destructive/10 text-destructive ring-destructive/20'}"
>
{#if isPending}
<FileText class="w-6 h-6" />
{:else if isFinished && !hasErrors}
<CheckCircle2 class="w-6 h-6" />
{:else}
<AlertTriangle class="w-6 h-6" />
{/if}
</div>
<div class="flex-1">
<Dialog.Title class="text-xl font-semibold tracking-tight text-foreground">
{#if isPending}
Validación de Importación
{:else if isFinished}
{hasErrors ? 'Importación con Observaciones' : 'Importación Exitosa'}
{/if}
</Dialog.Title>
<Dialog.Description class="mt-1 text-muted-foreground">
{#if isPending}
Revise el análisis preliminar antes de confirmar la carga de datos.
{:else if isFinished}
El proceso de importación ha finalizado.
{/if}
</Dialog.Description>
</div>
</div>
<!-- Scrollable Content -->
<div class="px-6 py-6 overflow-y-auto">
<!-- PENDING STATE CONTENT -->
{#if isPending}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<!-- Card: Total -->
<div
class="bg-card p-4 rounded-lg border flex flex-col items-center justify-center text-center shadow-sm"
>
<span class="text-muted-foreground text-xs uppercase font-bold tracking-wider mb-1"
>Total Filas</span
>
<span class="text-2xl font-bold text-foreground">{scanResults.total_rows || 0}</span>
</div>
<!-- Card: Valid -->
<div
class="bg-green-50/50 dark:bg-green-900/10 p-4 rounded-lg border border-green-100 dark:border-green-900/30 flex flex-col items-center justify-center text-center shadow-sm"
>
<span
class="text-green-600 dark:text-green-400 text-xs uppercase font-bold tracking-wider mb-1"
>Válidos</span
>
<span class="text-2xl font-bold text-green-700 dark:text-green-300"
>{scanResults.valid_rows || 0}</span
>
</div>
<!-- Card: Errors -->
<div
class="bg-destructive/5 p-4 rounded-lg border border-destructive/10 flex flex-col items-center justify-center text-center shadow-sm"
>
<span class="text-destructive text-xs uppercase font-bold tracking-wider mb-1"
>Errores</span
>
<span class="text-2xl font-bold text-destructive">{scanResults.error_count || 0}</span>
</div>
</div>
{#if scanResults.error_count > 0}
<div
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3"
>
<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.
</p>
</div>
</div>
{: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" />
<div class="text-sm text-primary/90">
<p class="font-semibold mb-1">Archivo validado correctamente</p>
<p>Todos los registros parecen correctos y listos para importar.</p>
</div>
</div>
{/if}
{/if}
<!-- FINISHED STATE CONTENT -->
{#if isFinished}
<div class="space-y-6">
<!-- Simplified 2-Column Stats Grid -->
<div class="grid grid-cols-2 gap-4">
<!-- Inserted (Green) -->
<div
class="bg-green-50/50 dark:bg-green-900/10 p-4 rounded-lg border border-green-100 dark:border-green-900/30 flex flex-col items-center justify-center text-center shadow-sm"
>
<div class="flex items-center gap-2 mb-1">
<CheckCircle2 class="w-4 h-4 text-green-600 dark:text-green-400" />
<span
class="text-green-600 dark:text-green-400 text-xs uppercase font-bold tracking-wider"
>Insertados</span
>
</div>
<span class="text-3xl font-bold text-green-700 dark:text-green-300"
>{commitResults.inserted || 0}</span
>
</div>
<!-- Rejected (Red) -->
<div
class="bg-destructive/5 p-4 rounded-lg border border-destructive/10 flex flex-col items-center justify-center text-center shadow-sm"
>
<div class="flex items-center gap-2 mb-1">
<XCircle class="w-4 h-4 text-destructive" />
<span class="text-destructive text-xs uppercase font-bold tracking-wider"
>Rechazados</span
>
</div>
<span class="text-3xl font-bold text-destructive">{totalSkipped}</span>
</div>
</div>
<!-- Error Details Table -->
{#if commitResults.skipped_details && commitResults.skipped_details.length > 0}
<div class="border rounded-lg overflow-hidden mt-2 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
</h5>
<span
class="text-[10px] bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border"
>
{commitResults.skipped_details.length} filas
</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-20">Línea</th>
<th class="px-4 py-2 w-32">Referencia</th>
<th class="px-4 py-2">Motivo</th>
</tr>
</thead>
<tbody class="divide-y">
{#each commitResults.skipped_details as detail}
<tr class="hover:bg-muted/30 transition-colors">
<td class="px-4 py-2 font-mono text-muted-foreground">{detail.line}</td>
<td class="px-4 py-2 font-mono font-medium text-foreground"
>{detail.invoice || '-'}</td
>
<td class="px-4 py-2 text-destructive">{detail.reason}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer Actions -->
<div class="px-6 py-4 bg-muted/20 border-t flex items-center justify-end gap-3">
{#if isPending}
<Button
variant="ghost"
onclick={onCancel}
disabled={isUploading}
class="text-muted-foreground hover:bg-muted/50"
>
Cancelar Operación
</Button>
<Button
onclick={onConfirm}
disabled={isUploading}
class="bg-primary hover:bg-primary/90 text-primary-foreground min-w-[140px] shadow-sm"
>
{#if isUploading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Procesando...
{:else}
<UploadCloud class="mr-2 h-4 w-4" />
Confirmar Carga
{/if}
</Button>
{:else if isFinished}
<Button variant="outline" onclick={onClose} class="min-w-[100px]">Cerrar</Button>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -34,7 +34,7 @@
defaultOperationType = undefined,
exchangeRate = undefined,
invoiceType = undefined
}: {
}: {
invoice: Invoice | null;
formData?: any;
invoiceTypes?: InvoiceType[];
@@ -193,7 +193,10 @@
const soldToHeaderOptions = $derived([
{ value: 'consignado_a', label: 'Consignado a' },
{ value: 'vendido_a', label: 'Vendido a' },
{ value: operationType === 1 ? 'exportado_a' : 'importador', label: operationType === 1 ? 'Exportado a' : 'Importador' }
{
value: operationType === 1 ? 'exportado_a' : 'importador',
label: operationType === 1 ? 'Exportado a' : 'Importador'
}
]);
const shippedToHeaderOptions = $derived(
@@ -213,21 +216,21 @@
const shippedByHeaderOptions = $derived(
operationType === 1 || invoiceType === 'CR'
? [
{ value: 'enviado_por', label: 'Enviado Por' },
{ value: 'destinatario', label: 'Destinatario' },
{ value: 'vendido_por', label: 'Vendido Por' },
{ value: 'consignado_a', label: 'Consignado a' },
{ value: 'vendido_a', label: 'Vendido a' },
{ value: 'exportado_a', label: 'Exportado a' },
{ value: 'enviado_a', label: 'Enviado a' },
{ value: 'transferido_a', label: 'Transferido a' },
{ value: 'donado_a', label: 'Donado a' },
{ value: 'notificar_a', label: 'Notificar a' }
]
{ value: 'enviado_por', label: 'Enviado Por' },
{ value: 'destinatario', label: 'Destinatario' },
{ value: 'vendido_por', label: 'Vendido Por' },
{ value: 'consignado_a', label: 'Consignado a' },
{ value: 'vendido_a', label: 'Vendido a' },
{ value: 'exportado_a', label: 'Exportado a' },
{ value: 'enviado_a', label: 'Enviado a' },
{ value: 'transferido_a', label: 'Transferido a' },
{ value: 'donado_a', label: 'Donado a' },
{ value: 'notificar_a', label: 'Notificar a' }
]
: [
{ value: 'enviado_a', label: 'Enviado a' },
{ value: 'transferido_a', label: 'Transferido a' }
]
{ value: 'enviado_a', label: 'Enviado a' },
{ value: 'transferido_a', label: 'Transferido a' }
]
);
// Combinar clientes y proveedores para shipped_to, evitando duplicados de tipo "both"
@@ -427,80 +430,85 @@
<span class="text-red-500">*</span>
</div>
<div class="grid grid-cols-4 gap-3 space-y-1.5">
<Select.Root
type="single"
value={formData.shipped_to_header || shippedToHeaderOptions[0]?.value || ''}
onValueChange={(v) => {
formData.shipped_to_header = v ?? '';
}}
>
<Select.Trigger id="shipped_to_header" class="h-7 text-xs min-w-[125px] max-w-[250px]">
<span class="truncate">
{shippedToHeaderOptions.find(o => o.value === (formData.shipped_to_header || shippedToHeaderOptions[0]?.value))?.label || 'Selecciona encabezado...'}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each shippedToHeaderOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
type="single"
value={formData.shipped_to_id ? String(formData.shipped_to_id) : ''}
onValueChange={(v) => {
formData.shipped_to_id = v ? parseInt(v) : null;
}}
>
<Select.Trigger id="shipped_to_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<span class="truncate">
{#if formData.shipped_to_id}
{allClientsProviders.find(cp => cp.id === formData.shipped_to_id)?.name || 'Selecciona...'}
{:else}
Selecciona...
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each allClientsProviders as cp}
<Select.Item value={String(cp.id)}>
{cp.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<span class="text-red-500">*</span>
</div>
<div class="grid grid-cols-4 gap-3 space-y-1.5">
<Select.Root
type="single"
value={formData.shipped_to_header || shippedToHeaderOptions[0]?.value || ''}
onValueChange={(v) => {
formData.shipped_to_header = v ?? '';
}}
>
<Select.Trigger id="shipped_to_header" class="h-7 text-xs min-w-[125px] max-w-[250px]">
<span class="truncate">
{shippedToHeaderOptions.find(
(o) => o.value === (formData.shipped_to_header || shippedToHeaderOptions[0]?.value)
)?.label || 'Selecciona encabezado...'}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each shippedToHeaderOptions as option}
<Select.Item value={option.value}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Select.Root
type="single"
value={formData.shipped_to_id ? String(formData.shipped_to_id) : ''}
onValueChange={(v) => {
formData.shipped_to_id = v ? parseInt(v) : null;
}}
>
<Select.Trigger id="shipped_to_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<span class="truncate">
{#if formData.shipped_to_id}
{allClientsProviders.find((cp) => cp.id === formData.shipped_to_id)?.name ||
'Selecciona...'}
{:else}
Selecciona...
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each allClientsProviders as cp}
<Select.Item value={String(cp.id)}>
{cp.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<span class="text-red-500">*</span>
</div>
<div class="space-y-1.5">
<Label for="customs_broker_id" class="text-xs">Agente Aduanal Mex: <span class="text-red-500">*</span></Label>
<Select.Root
type="single"
value={formData.customs_broker_id ? String(formData.customs_broker_id) : ''}
onValueChange={(v) => {
formData.customs_broker_id = v ? parseInt(v) : null;
}}
>
<Select.Trigger id="customs_broker_id" class="h-7 text-xs min-w-[150px] max-w-[300px]">
<span class="truncate">
{formData.customs_broker_id
? customsBrokers.find(cb => cb.id === formData.customs_broker_id)?.name || 'Selecciona...'
: 'Selecciona...'}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each customsBrokers as broker}
<Select.Item value={broker.id.toString()}>
{broker.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label for="customs_broker_id" class="text-xs"
>Agente Aduanal Mex: <span class="text-red-500">*</span></Label
>
<Select.Root
type="single"
value={formData.customs_broker_id ? String(formData.customs_broker_id) : ''}
onValueChange={(v) => {
formData.customs_broker_id = v ? parseInt(v) : null;
}}
>
<Select.Trigger id="customs_broker_id" class="h-7 text-xs min-w-[150px] max-w-[300px]">
<span class="truncate">
{formData.customs_broker_id
? customsBrokers.find((cb) => cb.id === formData.customs_broker_id)?.name ||
'Selecciona...'
: 'Selecciona...'}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each customsBrokers as broker}
<Select.Item value={broker.id.toString()}>
{broker.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label for="customs_broker_us_id" class="text-xs">Agente Aduanal Ame:</Label>
@@ -530,88 +538,101 @@
</div>
</div>
<!-- Columna Derecha: Tipo de Moneda y Transportista -->
<div class="space-y-3">
<!-- Tipo de Moneda - Pesos Netos y Brutos -->
<div class="border rounded-md p-3 space-y-2">
<div class="flex justify-between">
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Tipo de Moneda - Pesos Netos y Brutos</h4>
<h4 class="text-xs font-semibold text-muted-foreground uppercase">
Tipo de cambio:
<span class="text-primary ml-1">
{(exchangeRate !== undefined && exchangeRate !== null)
? (exchangeRate === 0 ? 'N/A' : Number(exchangeRate).toFixed(4))
: (formData.exchange_rate ? Number(formData.exchange_rate).toFixed(4) : 'N/A')}
</span>
</h4>
</div>
<!-- Radio buttons para tipo de moneda -->
<div class="space-y-1.5">
<RadioGroup.Root bind:value={formData.currency} class="flex gap-4">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="foreign" id="currency-foreign" class="h-4 w-4" />
<Label for="currency-foreign" class="text-xs font-normal cursor-pointer">Extranjera (Dlls)</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="local" id="currency-local" class="h-4 w-4" />
<Label for="currency-local" class="text-xs font-normal cursor-pointer">Nacional (Pesos)</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="manual" id="currency-manual" class="h-4 w-4" />
<Label for="currency-manual" class="text-xs font-normal cursor-pointer">De Captura</Label>
</div>
</RadioGroup.Root>
</div>
{#if formData.currency === 'manual'}
<div class="space-y-1.5">
<Label for="currency_type" class="text-xs">Moneda:</Label>
<Select.Root
type="single"
value={formData.currency_type || ''}
onValueChange={(v) => {
formData.currency_type = v ?? '';
}}
>
<Select.Trigger id="currency_type" class="h-7 text-xs min-w-[80px]">
<span class="truncate">
{formData.currency_type || '...'}
</span>
</Select.Trigger>
<Select.Content class="min-w-[80px] max-h-[300px]">
{#each currencyTypes as currencyType}
<Select.Item value={currencyType.code}>
{currencyType.code}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="grid grid-cols-3 gap-2">
<div class="space-y-1.5">
<Label for="weight_type" class="text-xs">Tipo Peso:</Label>
<Select.Root
type="single"
value={formData.weight_type || 'kgs'}
onValueChange={(v) => {
formData.weight_type = v ?? 'kgs';
}}
>
<Select.Trigger id="weight_type" class="min-w-[150px] h-7 text-xs">
<span class="truncate">
{weightTypeOptions.find(w => w.value === formData.weight_type)?.label || 'Kilogramos (kg)'}
</span>
</Select.Trigger>
<Select.Content>
{#each weightTypeOptions as weightType}
<Select.Item value={weightType.value}>
{weightType.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Columna Derecha: Tipo de Moneda y Transportista -->
<div class="space-y-3">
<!-- Tipo de Moneda - Pesos Netos y Brutos -->
<div class="border rounded-md p-3 space-y-2">
<div class="flex justify-between">
<h4 class="text-xs font-semibold text-muted-foreground uppercase">
Tipo de Moneda - Pesos Netos y Brutos
</h4>
<h4 class="text-xs font-semibold text-muted-foreground uppercase">
Tipo de cambio:
<span class="text-primary ml-1">
{exchangeRate !== undefined && exchangeRate !== null
? exchangeRate === 0
? 'N/A'
: Number(exchangeRate).toFixed(4)
: formData.exchange_rate
? Number(formData.exchange_rate).toFixed(4)
: 'N/A'}
</span>
</h4>
</div>
<!-- Radio buttons para tipo de moneda -->
<div class="space-y-1.5">
<RadioGroup.Root bind:value={formData.currency} class="flex gap-4">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="foreign" id="currency-foreign" class="h-4 w-4" />
<Label for="currency-foreign" class="text-xs font-normal cursor-pointer"
>Extranjera (Dlls)</Label
>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="local" id="currency-local" class="h-4 w-4" />
<Label for="currency-local" class="text-xs font-normal cursor-pointer"
>Nacional (Pesos)</Label
>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="manual" id="currency-manual" class="h-4 w-4" />
<Label for="currency-manual" class="text-xs font-normal cursor-pointer"
>De Captura</Label
>
</div>
</RadioGroup.Root>
</div>
{#if formData.currency === 'manual'}
<div class="space-y-1.5">
<Label for="currency_type" class="text-xs">Moneda:</Label>
<Select.Root
type="single"
value={formData.currency_type || ''}
onValueChange={(v) => {
formData.currency_type = v ?? '';
}}
>
<Select.Trigger id="currency_type" class="h-7 text-xs min-w-[80px]">
<span class="truncate">
{formData.currency_type || '...'}
</span>
</Select.Trigger>
<Select.Content class="min-w-[80px] max-h-[300px]">
{#each currencyTypes as currencyType}
<Select.Item value={currencyType.code}>
{currencyType.code}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="grid grid-cols-3 gap-2">
<div class="space-y-1.5">
<Label for="weight_type" class="text-xs">Tipo Peso:</Label>
<Select.Root
type="single"
value={formData.weight_type || 'kgs'}
onValueChange={(v) => {
formData.weight_type = v ?? 'kgs';
}}
>
<Select.Trigger id="weight_type" class="min-w-[150px] h-7 text-xs">
<span class="truncate">
{weightTypeOptions.find((w) => w.value === formData.weight_type)?.label ||
'Kilogramos (kg)'}
</span>
</Select.Trigger>
<Select.Content>
{#each weightTypeOptions as weightType}
<Select.Item value={weightType.value}>
{weightType.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if operationType !== 1 && invoiceType !== 'MEX' && invoiceType !== 'CR' && invoiceType !== 'REP' && invoiceType !== 'REPAR'}
<div class="space-y-1.5">
@@ -647,44 +668,46 @@
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Transportista -->
<div class="border rounded-md p-3 space-y-2">
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Transportista</h4>
<div class="grid grid-cols-3 gap-2">
<!-- Transportista -->
<div class="border rounded-md p-3 space-y-2">
<h4 class="text-xs font-semibold text-muted-foreground uppercase">Transportista</h4>
<div class="grid grid-cols-3 gap-2">
{#if invoiceType !== 'MEX'}
<div class="space-y-1.5">
<Label for="carrier_id" class="text-xs">Transportista:</Label>
<Select.Root
type="single"
value={formData.carrier_id ? String(formData.carrier_id) : ''}
onValueChange={(v) => {
formData.carrier_id = v || null;
}}
>
<Select.Trigger id="carrier_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<span class="truncate">
{#if formData.carrier_id}
{transporters.find(t => String(t.transporter_key) === String(formData.carrier_id))?.name || formData.carrier_id}
{:else if transporters.length > 0}
Selecciona transportista...
{:else}
Sin datos
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each transporters as transporter}
<Select.Item value={String(transporter.transporter_key)}>
{transporter.transporter_key}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Select.Root
type="single"
value={formData.carrier_id ? String(formData.carrier_id) : ''}
onValueChange={(v) => {
formData.carrier_id = v || null;
}}
>
<Select.Trigger id="carrier_id" class="h-7 text-xs min-w-[120px] max-w-[250px]">
<span class="truncate">
{#if formData.carrier_id}
{transporters.find(
(t) => String(t.transporter_key) === String(formData.carrier_id)
)?.name || formData.carrier_id}
{:else if transporters.length > 0}
Selecciona transportista...
{:else}
Sin datos
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each transporters as transporter}
<Select.Item value={String(transporter.transporter_key)}>
{transporter.transporter_key}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
<div class="col-span-2 space-y-1.5">
@@ -811,33 +834,34 @@
{#if invoiceType !== 'MEX'}
<div class="space-y-1.5">
<Label for="aduana" class="text-xs">Aduana y Sección de Despacho:</Label>
<Select.Root
type="single"
value={formData.aduana || ''}
onValueChange={(v) => {
formData.aduana = v ?? '';
}}
>
<Select.Trigger id="aduana" class="h-7 text-xs w-full">
<span class="truncate">
{#if formData.aduana}
{customsSections.find(cs => cs.customs_code === formData.aduana)?.section_name || formData.aduana}
{:else if customsSections.length > 0}
Selecciona aduana...
{:else}
Sin datos
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each customsSections as section}
<Select.Item value={section.customs_code}>
{section.customs_code} - {section.section_name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Select.Root
type="single"
value={formData.aduana || ''}
onValueChange={(v) => {
formData.aduana = v ?? '';
}}
>
<Select.Trigger id="aduana" class="h-7 text-xs w-full">
<span class="truncate">
{#if formData.aduana}
{customsSections.find((cs) => cs.customs_code === formData.aduana)
?.section_name || formData.aduana}
{:else if customsSections.length > 0}
Selecciona aduana...
{:else}
Sin datos
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each customsSections as section}
<Select.Item value={section.customs_code}>
{section.customs_code} - {section.section_name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
{#if invoiceType !== 'MEX'}
@@ -845,35 +869,36 @@
<Label for="document_type" class="text-xs"
>Clave de Régimen Aduanero: <span class="text-red-500">*</span></Label
>
<Select.Root
type="single"
value={formData.document_type || ''}
onValueChange={(v) => {
formData.document_type = v ?? '';
}}
>
<Select.Trigger id="document_type" class="h-7 text-xs w-full">
<span class="truncate">
{#if formData.document_type}
{codePedimentoRegimens.find(r => r.regimen_code === formData.document_type)?.regimen_code || formData.document_type}
{:else if filteredRegimens.length > 0}
Selecciona régimen...
{:else if operationType}
Sin regímenes para tipo {operationType}
{:else}
Selecciona tipo de operación primero
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each filteredRegimens as regimen}
<Select.Item value={regimen.regimen_code}>
{regimen.regimen_code}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Select.Root
type="single"
value={formData.document_type || ''}
onValueChange={(v) => {
formData.document_type = v ?? '';
}}
>
<Select.Trigger id="document_type" class="h-7 text-xs w-full">
<span class="truncate">
{#if formData.document_type}
{codePedimentoRegimens.find((r) => r.regimen_code === formData.document_type)
?.regimen_code || formData.document_type}
{:else if filteredRegimens.length > 0}
Selecciona régimen...
{:else if operationType}
Sin regímenes para tipo {operationType}
{:else}
Selecciona tipo de operación primero
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
{#each filteredRegimens as regimen}
<Select.Item value={regimen.regimen_code}>
{regimen.regimen_code}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
{#if invoiceType === 'MEX'}
@@ -891,8 +916,8 @@
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
<ManifestSelectorModal bind:open={showManifestModal} onSelect={handleManifestSelect} />

View File

@@ -224,7 +224,7 @@ export const importacionConfig: CsvUploadItem[] = [
title: 'Encabezado',
icon: FileText,
group: 'Impo. Temp.',
modelTarget: 'InvoiceHeader',
modelTarget: 'invoice_header',
templateUrl: '/csv/EstructuraEncFacImpoTemp.xls'
},
{
@@ -232,7 +232,7 @@ export const importacionConfig: CsvUploadItem[] = [
title: 'Partidas',
icon: Package,
group: 'Impo. Temp.',
modelTarget: 'InvoiceSalesDetails',
modelTarget: 'invoice_details',
templateUrl: '/csv/EstructuraParFacImpoTempAF.xls'
},
{

View File

@@ -2,6 +2,7 @@
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,
@@ -10,42 +11,109 @@
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 allSettings = $state<Record<string, any>>({});
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);
$effect(() => {
const fields = tabSettings[activeTab] || [];
if (!allSettings[activeTab]) {
allSettings[activeTab] = {};
fields.forEach((f) => {
allSettings[activeTab][f.name] = f.defaultValue;
// 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;
});
function handleUpload(file: File, config: CsvUploadItem) {
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';
console.log('🚀 Starting Direct Upload Processing', {
file: file.name,
size: file.size,
target: config.modelTarget,
config: config.title,
settings: currentSettings
});
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;
}
}
// Mock Processing Feedback
const promise = new Promise((resolve) => setTimeout(resolve, 2000));
async function pollStatus() {
if (!currentJobId) return;
toast.promise(promise, {
loading: `Procesando ${file.name} para ${config.title}...`,
success: `Archivo cargado correctamente con configuración: ${JSON.stringify(currentSettings)}`,
error: 'Error al cargar el archivo'
});
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>
@@ -105,3 +173,37 @@
</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;
}}
/>