fix/carga-csv-ergonomia

This commit is contained in:
2026-05-06 14:24:41 -06:00
parent 8c8cd0e247
commit daa3a252b9
53 changed files with 3164 additions and 391 deletions

View File

@@ -681,16 +681,23 @@ export const api = {
* Returns blob and suggested filename for the browser download.
*/
async getCsvTemplateDownload(
templateId: string
templateId: string,
locale?: string
): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE_URL}/v1/a76/csv-templates/${templateId}`, {
method: 'GET',
headers,
credentials: 'include'
});
const loc = locale === 'en' ? 'en' : 'es';
const qs = new URLSearchParams({ locale: loc });
const response = await fetch(
`${API_BASE_URL}/v1/a76/csv-templates/${templateId}?${qs.toString()}`,
{
method: 'GET',
headers,
credentials: 'include',
cache: 'no-store'
}
);
if (!response.ok) {
const msg = response.status === 404 ? 'Plantilla no encontrada' : `Error ${response.status}`;
throw new Error(msg);

View File

@@ -5,6 +5,14 @@
import * as RadioGroup from '$lib/components/ui/radio-group/index.js';
import { Settings2 } from 'lucide-svelte';
import { tabSettings } from '$lib/config/csv-upload';
import { csvMsg } from '$lib/i18n/csv-msg';
const TAB_LABEL: Record<string, string> = {
catalogos: 'tab_catalogos',
transportes: 'tab_transportes',
importacion: 'tab_importacion',
exportacion: 'tab_exportacion'
};
let {
activeTab,
@@ -38,7 +46,9 @@
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2 text-muted-foreground border-b pb-2">
<Settings2 class="h-4 w-4" />
<span class="text-xs font-semibold uppercase tracking-wider">Configuración: {activeTab}</span>
<span class="text-xs font-semibold uppercase tracking-wider"
>{csvMsg('config_prefix')}: {csvMsg(TAB_LABEL[activeTab] ?? activeTab)}</span
>
</div>
{#if currentFields.length > 0}
@@ -47,7 +57,7 @@
<div class="flex flex-col gap-2">
{#if field.type !== 'boolean'}
<Label class="text-xs font-medium text-muted-foreground uppercase"
>{field.label}</Label
>{csvMsg(field.labelKey)}</Label
>
{/if}
@@ -59,7 +69,7 @@
<Label
for={field.name}
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>{field.label}</Label
>{csvMsg(field.labelKey)}</Label
>
</div>
{:else if field.type === 'select' && field.options}
@@ -68,7 +78,7 @@
bind:value={settings[field.name]}
>
{#each field.options as opt}
<option value={opt.value}>{opt.label}</option>
<option value={opt.value}>{csvMsg(opt.labelKey)}</option>
{/each}
</select>
{:else if field.type === 'radio' && field.options}
@@ -79,7 +89,7 @@
{#each field.options as opt}
<div class="flex items-center space-x-2">
<RadioGroup.Item value={opt.value} id={`${field.name}-${opt.value}`} />
<Label for={`${field.name}-${opt.value}`}>{opt.label}</Label>
<Label for={`${field.name}-${opt.value}`}>{csvMsg(opt.labelKey)}</Label>
</div>
{/each}
</RadioGroup.Root>
@@ -89,7 +99,7 @@
</div>
{:else}
<div class="flex items-center justify-center h-8 text-sm text-muted-foreground italic">
No hay configuraciones específicas para este módulo.
{csvMsg('config_empty')}
</div>
{/if}
</div>

View File

@@ -3,6 +3,14 @@
import { Settings2 } from 'lucide-svelte';
import { globalCsvParams, tabSettings, type CsvUploadField } from '$lib/config/csv-upload';
import { cn } from '$lib/utils';
import { csvMsg } from '$lib/i18n/csv-msg';
const TAB_LABEL: Record<string, string> = {
catalogos: 'tab_catalogos',
transportes: 'tab_transportes',
importacion: 'tab_importacion',
exportacion: 'tab_exportacion'
};
let {
globalSettings = $bindable(),
@@ -45,13 +53,13 @@
<div class="flex items-center gap-2 text-muted-foreground border-r border-border pr-4">
<Settings2 class="h-4 w-4 shrink-0" />
<span class="text-xs font-semibold uppercase tracking-wider whitespace-nowrap"
>Parámetros globales</span
>{csvMsg('params_header')}</span
>
</div>
{#each globalCsvParams as param}
<div class="flex flex-col gap-1">
<Label for="global-{param.name}" class="text-xs font-medium text-muted-foreground"
>{param.label}</Label
>{csvMsg(param.labelKey)}</Label
>
<select
id="global-{param.name}"
@@ -62,7 +70,7 @@
}}
>
{#each param.options as opt}
<option value={opt.value}>{opt.label}</option>
<option value={opt.value}>{csvMsg(opt.labelKey)}</option>
{/each}
</select>
</div>
@@ -73,13 +81,13 @@
{#if currentTabFields.length > 0}
<div class="flex flex-wrap items-center gap-4 border-l border-border pl-4">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground whitespace-nowrap"
>Configuración: {activeTab}</span
>{csvMsg('config_prefix')}: {csvMsg(TAB_LABEL[activeTab] ?? activeTab)}</span
>
{#each currentTabFields as field}
<div class="flex flex-col gap-1">
{#if field.type !== 'boolean'}
<Label for="tab-{field.name}" class="text-xs font-medium text-muted-foreground"
>{field.label}</Label
>{csvMsg(field.labelKey)}</Label
>
{/if}
{#if field.type === 'select' && field.options}
@@ -92,7 +100,7 @@
}}
>
{#each field.options as opt}
<option value={opt.value}>{opt.label}</option>
<option value={opt.value}>{csvMsg(opt.labelKey)}</option>
{/each}
</select>
{:else if field.type === 'radio' && field.options}
@@ -108,7 +116,7 @@
if (tabSettingsValues) tabSettingsValues[field.name] = opt.value;
}}
/>
{opt.label}
{csvMsg(opt.labelKey)}
</label>
{/each}
</div>
@@ -121,7 +129,7 @@
if (tabSettingsValues) tabSettingsValues[field.name] = e.currentTarget.checked;
}}
/>
{field.label}
{csvMsg(field.labelKey)}
</label>
{/if}
</div>

View File

@@ -14,6 +14,7 @@
} from '$lib/csv-import-pending';
import { fetchCsvImportStatus, isWaitingConfirmationPayload } from '$lib/csv-import-status-api';
import { Loader2, RefreshCw } from 'lucide-svelte';
import { csvMsg } from '$lib/i18n/csv-msg';
type ValidatedRow = CsvImportPendingEntry & { checking?: boolean };
@@ -36,22 +37,9 @@
}
function profileLabel(p: CsvImportPendingEntry['profile']): string {
const map: Record<CsvImportPendingEntry['profile'], string> = {
customs_brokers: 'Agentes aduanales',
clients_providers: 'Clientes / proveedores',
exchange_rates: 'Tipos de cambio',
pedimentos: 'Pedimentos',
material_classes: 'Clases de material',
vehicles: 'Vehículos',
drivers: 'Conductores',
trailers: 'Remolques',
transporters: 'Transportistas',
part_numbers: 'Números de parte',
boms: 'BOMs',
exportacion: 'Exportación (operaciones)',
imports: 'Importación (operaciones)'
};
return map[p] ?? p;
const key = `pending.profiles.${p}` as const;
const t = csvMsg(key);
return t === key ? p : t;
}
function isStaleJob(status: number, err: string | undefined): boolean {
@@ -134,7 +122,7 @@
</script>
<Button variant="outline" size="sm" class="shrink-0" type="button" onclick={() => (open = true)}>
Pendientes
{csvMsg('pending.badge')}
{#if badgeCount > 0}
<span class="ml-1.5 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
{badgeCount}
@@ -145,10 +133,9 @@
<Sheet.Root bind:open>
<Sheet.Content side="right" class="flex w-full max-w-lg flex-col sm:max-w-xl">
<Sheet.Header>
<Sheet.Title>Importaciones pendientes de confirmar</Sheet.Title>
<Sheet.Title>{csvMsg('pending.title')}</Sheet.Title>
<Sheet.Description>
Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al
actualizar.
{csvMsg('pending.description')}
</Sheet.Description>
</Sheet.Header>
<div class="flex items-center justify-end gap-2 border-b px-4 py-2">
@@ -158,14 +145,14 @@
{:else}
<RefreshCw class="mr-2 h-4 w-4" />
{/if}
Actualizar
{csvMsg('pending.refresh')}
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{#if rows.length === 0 && !refreshing}
<p class="text-sm text-muted-foreground">No hay importaciones pendientes para esta empresa.</p>
<p class="text-sm text-muted-foreground">{csvMsg('pending.empty')}</p>
{:else if rows.length === 0 && refreshing}
<p class="text-sm text-muted-foreground">Comprobando con el servidor…</p>
<p class="text-sm text-muted-foreground">{csvMsg('pending.checking')}</p>
{:else}
<ul class="space-y-3">
{#each rows as row (row.jobId)}
@@ -177,10 +164,10 @@
{#if row.totalRows != null || row.validRows != null}
<div class="mt-1 text-xs text-muted-foreground">
{#if row.totalRows != null}
Total filas: {row.totalRows}
{csvMsg('pending.total_rows')}: {row.totalRows}
{/if}
{#if row.validRows != null}
<span class={row.totalRows != null ? ' · ' : ''}>Válidas: {row.validRows}</span>
<span class={row.totalRows != null ? ' · ' : ''}>{csvMsg('pending.valid_rows')}: {row.validRows}</span>
{/if}
</div>
{/if}
@@ -201,9 +188,9 @@
}
}}
>
Reanudar
{csvMsg('pending.resume')}
</Button>
<Button size="sm" variant="ghost" onclick={() => removeLocal(row.jobId)}>Quitar</Button>
<Button size="sm" variant="ghost" onclick={() => removeLocal(row.jobId)}>{csvMsg('pending.remove')}</Button>
</div>
</li>
{/each}

View File

@@ -16,6 +16,7 @@
criticalReferenceGaps,
referenceStateReady
} from '$lib/csv-import-commit-metrics';
import { csvFmt, csvMsg } from '$lib/i18n/csv-msg';
let {
open = $bindable(false),
@@ -249,16 +250,16 @@
<div class="flex-1">
<Dialog.Title class="text-xl font-semibold tracking-tight text-foreground">
{#if isPending}
Validación de Importación
{csvMsg('modal.title_pending')}
{:else if isFinished}
{hasErrors ? 'Importación con Observaciones' : 'Importación Exitosa'}
{hasErrors ? csvMsg('modal.title_warning') : csvMsg('modal.title_success')}
{/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.
{csvMsg('modal.desc_pending')}
{:else if isFinished}
El proceso de importación ha finalizado.
{csvMsg('modal.desc_done')}
{/if}
</Dialog.Description>
</div>
@@ -274,7 +275,7 @@
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
>{csvMsg('modal.total_rows')}</span
>
<span class="text-2xl font-bold text-foreground">{scanResults.total_rows || 0}</span>
</div>
@@ -285,7 +286,7 @@
>
<span
class="text-green-600 dark:text-green-400 text-xs uppercase font-bold tracking-wider mb-1"
>Válidos</span
>{csvMsg('modal.valid_rows')}</span
>
<span class="text-2xl font-bold text-green-700 dark:text-green-300"
>{scanResults.valid_rows || 0}</span
@@ -297,7 +298,7 @@
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
>{csvMsg('modal.errors')}</span
>
<span class="text-2xl font-bold text-destructive">{scanResults.error_count || 0}</span>
</div>
@@ -327,10 +328,9 @@
>
<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 class="font-semibold mb-1">{csvMsg('modal.scan_problems_title')}</p>
<p>
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).
{csvMsg('modal.scan_problems_body')}
</p>
</div>
</div>
@@ -338,13 +338,13 @@
<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)
{csvMsg('modal.errors_heading')}
</h5>
<div class="flex items-center gap-2">
<span
class="text-[10px] bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border"
>
{scanErrorsShown} de {scanErrorsTotal} error(es)
{csvFmt('modal.errors_badge', { shown: scanErrorsShown, total: scanErrorsTotal })}
</span>
<Button
variant="ghost"
@@ -352,7 +352,7 @@
class="h-7 px-2 text-xs"
>
<FileText class="w-3.5 h-3.5 mr-1" />
Descargar CSV
{csvMsg('modal.download_csv')}
</Button>
</div>
</div>
@@ -362,10 +362,10 @@
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>
<th class="px-4 py-2 min-w-[240px]">Solución</th>
<th class="px-4 py-2 w-16">{csvMsg('modal.th_line')}</th>
<th class="px-4 py-2 w-40">{csvMsg('modal.th_column')}</th>
<th class="px-4 py-2">{csvMsg('modal.th_message')}</th>
<th class="px-4 py-2 min-w-[240px]">{csvMsg('modal.th_solution')}</th>
</tr>
</thead>
<tbody class="divide-y">
@@ -382,21 +382,21 @@
</div>
{#if scanErrorsTruncated}
<p class="text-xs text-muted-foreground px-4 py-2">
Para consultar el resto de errores, descargue el CSV.
{csvMsg('modal.errors_truncated')}
</p>
{/if}
</div>
{:else}
<p class="text-sm text-muted-foreground mt-1">
Se detectaron {scanResults.error_count} fila(s) con errores pero el detalle no está disponible. Asegúrese de que el servidor esté actualizado y vuelva a subir el archivo.
{csvFmt('modal.errors_missing_detail', { count: scanResults.error_count })}
</p>
{/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" />
<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>
<p class="font-semibold mb-1">{csvMsg('modal.scan_ok_title')}</p>
<p>{csvMsg('modal.scan_ok_body')}</p>
</div>
</div>
{/if}
@@ -413,7 +413,7 @@
<CheckCircle2 class="h-4 w-4 text-green-600 dark:text-green-400" />
<span
class="text-xs font-bold uppercase tracking-wider text-green-600 dark:text-green-400"
>Insertados</span
>{csvMsg('modal.inserted')}</span
>
</div>
<span class="text-3xl font-bold text-green-700 dark:text-green-300">{insertedCount}</span>
@@ -425,7 +425,7 @@
<CheckCircle2 class="h-4 w-4 text-green-600 dark:text-green-400" />
<span
class="text-xs font-bold uppercase tracking-wider text-green-600 dark:text-green-400"
>Actualizados</span
>{csvMsg('modal.updated')}</span
>
</div>
<span class="text-3xl font-bold text-green-700 dark:text-green-300">{updatedCount}</span>
@@ -436,13 +436,13 @@
<div class="mb-1 flex items-center gap-2">
<XCircle class="h-4 w-4 text-destructive" />
<span class="text-xs font-bold uppercase tracking-wider text-destructive"
>Rechazados</span
>{csvMsg('modal.rejected')}</span
>
</div>
<span class="text-3xl font-bold text-destructive">{totalSkipped}</span>
{#if totalSkipped > 0 && commitResults.skipped_details && commitResults.skipped_details.length > 0}
<p class="mt-2 text-xs text-muted-foreground">
Revisa el detalle por línea en la tabla inferior.
{csvMsg('modal.rejected_hint')}
</p>
{/if}
</div>
@@ -456,20 +456,17 @@
{#if refGaps > 0}
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div>
<p class="font-semibold text-foreground">Brechas de referencia (FK / catálogos)</p>
<p class="font-semibold text-foreground">{csvMsg('modal.ref_gaps_title')}</p>
<p class="mt-1 text-muted-foreground">
Hay {refGaps} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de
filas rechazadas antes de reintentar.
{csvFmt('modal.ref_gaps_body', { n: refGaps })}
</p>
</div>
{:else}
<CheckCircle2 class="mt-0.5 h-5 w-5 shrink-0 text-green-600 dark:text-green-500" />
<div>
<p class="font-semibold text-foreground">Estado de referencias</p>
<p class="font-semibold text-foreground">{csvMsg('modal.ref_state_title')}</p>
<p class="mt-1 text-muted-foreground">
{refReady
? 'Referencias listas para operar (sin brechas críticas reportadas).'
: 'Sin brechas numéricas; revisa el mensaje del servidor si aplica.'}
{refReady ? csvMsg('modal.ref_state_ok') : csvMsg('modal.ref_state_other')}
</p>
</div>
{/if}
@@ -483,7 +480,7 @@
{#if commitSkippedSummary.length > 0}
<div>
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Resumen de motivos de rechazo
{csvMsg('modal.skipped_reasons_heading')}
</p>
<div class="flex flex-wrap gap-2">
{#each commitSkippedSummary as item}
@@ -504,13 +501,13 @@
<div id="detalle-errores-import" 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
{csvMsg('modal.commit_errors_heading')}
</h5>
<div class="flex items-center gap-2">
<span
class="text-[10px] bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border"
>
{commitResults.skipped_details.length} filas
{csvFmt('modal.rows_badge', { n: commitResults.skipped_details.length })}
</span>
<Button
variant="ghost"
@@ -518,7 +515,7 @@
class="h-7 px-2 text-xs"
>
<FileText class="w-3.5 h-3.5 mr-1" />
Descargar CSV
{csvMsg('modal.download_csv')}
</Button>
</div>
</div>
@@ -528,10 +525,10 @@
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>
<th class="px-4 py-2 min-w-[240px]">Solución</th>
<th class="px-4 py-2 w-20">{csvMsg('modal.th_line')}</th>
<th class="px-4 py-2 w-32">{csvMsg('modal.th_reference')}</th>
<th class="px-4 py-2">{csvMsg('modal.th_reason')}</th>
<th class="px-4 py-2 min-w-[240px]">{csvMsg('modal.th_solution')}</th>
</tr>
</thead>
<tbody class="divide-y">
@@ -558,7 +555,7 @@
<div class="flex flex-col gap-3 border-t bg-muted/20 px-6 py-4">
{#if isPending && isUploading}
<div class="space-y-2" role="status" aria-live="polite" aria-busy="true">
<p class="text-xs font-medium text-muted-foreground">Importando registros…</p>
<p class="text-xs font-medium text-muted-foreground">{csvMsg('modal.importing_records')}</p>
<div class="relative h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
class="csv-commit-indeterminate-bar absolute top-0 h-full w-2/5 rounded-full bg-primary"
@@ -574,7 +571,7 @@
disabled={isUploading}
class="text-muted-foreground hover:bg-muted/50"
>
Cancelar Operación
{csvMsg('modal.cancel_operation')}
</Button>
<Button
onclick={onConfirm}
@@ -583,14 +580,14 @@
>
{#if isUploading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Procesando...
{csvMsg('modal.processing')}
{:else}
<UploadCloud class="mr-2 h-4 w-4" />
Confirmar Carga
{csvMsg('modal.confirm_load')}
{/if}
</Button>
{:else if isFinished}
<Button variant="outline" onclick={onClose} class="min-w-[100px]">Cerrar</Button>
<Button variant="outline" onclick={onClose} class="min-w-[100px]">{csvMsg('modal.close')}</Button>
{/if}
</div>
</div>

View File

@@ -4,7 +4,21 @@
import { UploadCloud, Lock } from 'lucide-svelte';
import { cn } from '$lib/utils';
import { toast } from 'svelte-sonner';
import { browser } from '$app/environment';
import { api } from '$lib/api';
import { cookieName, getLocale } from '$lib/paraglide/runtime';
import { csvMsg } from '$lib/i18n/csv-msg';
/** Misma fuente que el switch de idioma (cookie Paraglide); `getLocale()` puede quedar desincronizado. */
function csvTemplateLocale(): 'es' | 'en' {
if (browser) {
const cookies = document.cookie.split(';').map((c) => c.trim());
const localeCookie = cookies.find((c) => c.startsWith(`${cookieName}=`));
const current = localeCookie ? localeCookie.split('=')[1] : '';
if (current) return current.toLowerCase().startsWith('en') ? 'en' : 'es';
}
return String(getLocale()).toLowerCase().startsWith('en') ? 'en' : 'es';
}
let {
items,
@@ -62,7 +76,7 @@
const isValidExtension = file.name.toLowerCase().endsWith('.csv');
if (!isValidExtension) {
toast.error('Formato inválido. Solo se permiten archivos .csv');
toast.error(csvMsg('toast.invalid_csv'));
return;
}
@@ -106,8 +120,12 @@
if (!item.templateId) return;
try {
toast.info(`Descargando plantilla para ${item.title}...`);
const { blob, filename } = await api.getCsvTemplateDownload(item.templateId);
const itemLabel = csvMsg(`items.${item.id}`);
toast.info(`${csvMsg('toast.download_loading')} ${itemLabel}`);
const { blob, filename } = await api.getCsvTemplateDownload(
item.templateId,
csvTemplateLocale()
);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
@@ -116,9 +134,9 @@
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(`Plantilla descargada: ${filename}`);
toast.success(`${csvMsg('toast.download_ok')} ${filename}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Error al descargar la plantilla');
toast.error(err instanceof Error ? err.message : csvMsg('toast.download_err'));
}
}
</script>
@@ -128,6 +146,10 @@
class:opacity-60={gridLocked}
aria-busy={gridLocked ? true : undefined}
>
{#snippet cardTitle(itemId: string)}
<div class="font-medium text-sm text-balance">{csvMsg(`items.${itemId}`)}</div>
{/snippet}
{#if groupedItems.ungrouped.length > 0}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{#each groupedItems.ungrouped as item}
@@ -171,7 +193,7 @@
<span
class="bg-muted px-2 py-1 rounded text-xs font-semibold text-muted-foreground border flex items-center gap-1"
>
<Lock class="h-3 w-3" /> Próximamente
<Lock class="h-3 w-3" /> {csvMsg('soon')}
</span>
</div>
{/if}
@@ -183,7 +205,7 @@
<div class="animate-bounce">
<UploadCloud class="h-8 w-8 text-primary" />
</div>
<span class="text-sm font-semibold text-primary">¡Suelta el archivo!</span>
<span class="text-sm font-semibold text-primary">{csvMsg('drop_here')}</span>
{:else}
<div
class={cn(
@@ -193,7 +215,7 @@
>
<item.icon class="h-6 w-6 text-primary" />
</div>
<div class="font-medium text-sm text-balance">{item.title}</div>
{@render cardTitle(item.id)}
{/if}
</Card.Content>
</Card.Root>
@@ -205,7 +227,7 @@
{#each Object.entries(groupedItems.groups) as [groupName, groupItems]}
<div class="flex flex-col gap-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider pl-1">
{groupName}
{csvMsg(`groups.${groupName}`)}
</h3>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{#each groupItems as item}
@@ -251,7 +273,7 @@
<span
class="bg-muted px-2 py-1 rounded text-xs font-semibold text-muted-foreground border flex items-center gap-1"
>
<Lock class="h-3 w-3" /> Próximamente
<Lock class="h-3 w-3" /> {csvMsg('soon')}
</span>
</div>
{/if}
@@ -263,7 +285,7 @@
<div class="animate-bounce">
<UploadCloud class="h-8 w-8 text-primary" />
</div>
<span class="text-sm font-semibold text-primary">¡Suelta el archivo!</span>
<span class="text-sm font-semibold text-primary">{csvMsg('drop_here')}</span>
{:else}
<div
class={cn(
@@ -273,7 +295,7 @@
>
<item.icon class="h-6 w-6 text-primary" />
</div>
<div class="font-medium text-sm text-balance">{item.title}</div>
{@render cardTitle(item.id)}
{/if}
</Card.Content>
</Card.Root>

View File

@@ -91,6 +91,18 @@ export function getSidebarData(): SidebarData {
items: [],
permission: 'audit_logs.view',
},
{
title: m["sidebar.bulk_upload.title"](),
url: "#",
icon: ArrowUpFromLine,
items: [
{
title: m["sidebar.bulk_upload.entry"](),
url: "/dashboard/csv-upload",
permission: "csv_upload.process",
},
],
},
{
title: m["sidebar.reference_data.title"](),

View File

@@ -1,13 +1,6 @@
<script lang="ts">
import { tick } from 'svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import FolderIcon from '@lucide/svelte/icons/folder';
import ForwardIcon from '@lucide/svelte/icons/forward';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { helpStore } from '$lib/stores/help.svelte';
import { m } from '$lib/i18n/messages';
let {
projects
@@ -15,28 +8,14 @@
projects: {
name: string;
url: string;
// This should be `Component` after @lucide/svelte updates types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: any;
}[];
} = $props();
const sidebar = useSidebar();
let open = $state(false);
let position = $state({ x: 0, y: 0 });
async function handleMoreClick(e: MouseEvent) {
e.preventDefault();
open = false;
position = { x: e.clientX, y: e.clientY };
await tick();
open = true;
}
</script>
<Sidebar.Group class="group-data-[collapsible=icon]:hidden">
<Sidebar.GroupLabel>Gestion</Sidebar.GroupLabel>
<Sidebar.GroupLabel>{m['sidebar.management_label']()}</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each projects as item (item.name)}
<Sidebar.MenuItem>
@@ -48,58 +27,7 @@
</a>
{/snippet}
</Sidebar.MenuButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuAction showOnHover {...props}>
<EllipsisIcon />
<span class="sr-only">More</span>
</Sidebar.MenuAction>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-48 rounded-lg"
side={sidebar.isMobile ? 'bottom' : 'right'}
align={sidebar.isMobile ? 'end' : 'start'}
>
<DropdownMenu.Item>
<FolderIcon class="text-muted-foreground" />
<span>View Project</span>
</DropdownMenu.Item>
<DropdownMenu.Item>
<ForwardIcon class="text-muted-foreground" />
<span>Share Project</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<Trash2Icon class="text-muted-foreground" />
<span>Delete Project</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
{/each}
<Sidebar.MenuItem>
<Sidebar.MenuButton class="text-sidebar-foreground/70" onclick={handleMoreClick}>
<EllipsisIcon class="text-sidebar-foreground/70" />
<span>More</span>
</Sidebar.MenuButton>
<DropdownMenu.Root open={open} onOpenChange={(v) => (open = v)}>
<DropdownMenu.Trigger
class="fixed z-50 size-0"
style="top: {position.y}px; left: {position.x}px"
/>
<DropdownMenu.Content class="w-48 rounded-lg" side="right" align="start">
<DropdownMenu.Item>
<a href="/dashboard/csv-upload" class="flex w-full items-center gap-2">
<FolderIcon class="size-4 text-muted-foreground" />
<span>Carga CSV</span>
</a>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>

View File

@@ -22,9 +22,10 @@ import {
export interface CsvUploadItem {
id: string;
title: string;
/** Etiqueta vía i18n: csv_upload.items.<id> */
icon: any;
group?: string; // For grouping within a tab
/** Clave i18n csv_upload.groups.<group> */
group?: string;
modelTarget?: string; // The backend model this maps to
description?: string;
/** Backend template id for CSV download (e.g. customs_brokers, part_numbers). No physical file. */
@@ -36,9 +37,10 @@ export interface CsvUploadItem {
export interface CsvUploadField {
name: string;
label: string;
/** Ruta bajo csv_upload.* (p. ej. params.load_mode) */
labelKey: string;
type: 'text' | 'select' | 'boolean' | 'date' | 'radio';
options?: { label: string; value: string | boolean | number }[];
options?: { labelKey: string; value: string | boolean | number }[];
required?: boolean;
defaultValue?: any;
}
@@ -46,9 +48,9 @@ export interface CsvUploadField {
/** Global parameters shown in the CSV upload footer bar (one option or the other via select). */
export interface GlobalCsvParam {
name: string;
label: string;
labelKey: string;
type: 'select';
options: { label: string; value: string }[];
options: { labelKey: string; value: string }[];
defaultValue: string;
}
@@ -56,62 +58,62 @@ export interface GlobalCsvParam {
export const globalCsvParams: GlobalCsvParam[] = [
{
name: 'mode',
label: 'Modo de Carga',
labelKey: 'params.load_mode',
type: 'select',
options: [
{ label: 'Actualizar', value: 'update' },
{ label: 'Reemplazar', value: 'replace' }
{ labelKey: 'options.update', value: 'update' },
{ labelKey: 'options.replace', value: 'replace' }
],
defaultValue: 'update'
},
{
name: 'dateFormat',
label: 'Formato de Fecha',
labelKey: 'params.date_format',
type: 'select',
options: [
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
{ labelKey: 'options.date_dd_mm', value: 'dd/mm/yyyy' },
{ labelKey: 'options.date_mm_dd', value: 'mm/dd/yyyy' },
{ labelKey: 'options.date_iso', value: 'yyyy-mm-dd' }
],
defaultValue: 'dd/mm/yyyy'
},
{
name: 'weight_unit',
label: 'Unidad de Peso',
labelKey: 'params.weight_unit',
type: 'select',
options: [
{ label: 'Kilos (Kgs)', value: 'kgs' },
{ label: 'Libras (Lbs)', value: 'lbs' }
{ labelKey: 'options.kgs', value: 'kgs' },
{ labelKey: 'options.lbs', value: 'lbs' }
],
defaultValue: 'kgs'
},
{
name: 'autonumber_series',
label: 'Autonumerar Partidas/Series',
labelKey: 'params.autonumber_series',
type: 'select',
options: [
{ label: '', value: 'true' },
{ label: 'No', value: 'false' }
{ labelKey: 'options.yes', value: 'true' },
{ labelKey: 'options.no', value: 'false' }
],
defaultValue: 'false'
},
{
name: 'load_subpartidas',
label: 'Levantar Subpartidas',
labelKey: 'params.load_subpartidas',
type: 'select',
options: [
{ label: '', value: 'true' },
{ label: 'No', value: 'false' }
{ labelKey: 'options.yes', value: 'true' },
{ labelKey: 'options.no', value: 'false' }
],
defaultValue: 'false'
},
{
name: 'recalculate_pedimento_date',
label: 'Recalcular Fecha Pedimento',
labelKey: 'params.recalculate_pedimento_date',
type: 'select',
options: [
{ label: '', value: 'true' },
{ label: 'No', value: 'false' }
{ labelKey: 'options.yes', value: 'true' },
{ labelKey: 'options.no', value: 'false' }
],
defaultValue: 'false'
}
@@ -122,11 +124,11 @@ export const tabSettings: Record<string, CsvUploadField[]> = {
catalogos: [
{
name: 'mode',
label: 'Modo de Carga',
labelKey: 'params.load_mode',
type: 'radio',
options: [
{ label: 'Actualizar', value: 'update' },
{ label: 'Reemplazar', value: 'replace' }
{ labelKey: 'options.update', value: 'update' },
{ labelKey: 'options.replace', value: 'replace' }
],
defaultValue: 'update'
}
@@ -134,11 +136,11 @@ export const tabSettings: Record<string, CsvUploadField[]> = {
transportes: [
{
name: 'mode',
label: 'Modo de Carga',
labelKey: 'params.load_mode',
type: 'radio',
options: [
{ label: 'Actualizar', value: 'update' },
{ label: 'Reemplazar', value: 'replace' }
{ labelKey: 'options.update', value: 'update' },
{ labelKey: 'options.replace', value: 'replace' }
],
defaultValue: 'update'
}
@@ -146,24 +148,24 @@ export const tabSettings: Record<string, CsvUploadField[]> = {
importacion: [
{
name: 'autonumber_remesas',
label: 'Autonumerar Remesas',
labelKey: 'params.autonumber_remesas',
type: 'boolean',
defaultValue: false
},
{
name: 'recalculate_dates',
label: 'Recalcular Fechas',
labelKey: 'params.recalculate_dates',
type: 'boolean',
defaultValue: false
},
{
name: 'dateFormat',
label: 'Formato de Fecha',
labelKey: 'params.date_format',
type: 'select',
options: [
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
{ labelKey: 'options.date_dd_mm', value: 'dd/mm/yyyy' },
{ labelKey: 'options.date_mm_dd', value: 'mm/dd/yyyy' },
{ labelKey: 'options.date_iso', value: 'yyyy-mm-dd' }
],
defaultValue: 'dd/mm/yyyy'
}
@@ -171,28 +173,28 @@ export const tabSettings: Record<string, CsvUploadField[]> = {
exportacion: [
{
name: 'invoice_type',
label: 'Tipo de Factura',
labelKey: 'params.invoice_type',
type: 'select',
options: [
{ label: 'AFIJO', value: 'AFIJO' },
{ label: 'NORMAL', value: 'NORMAL' },
{ labelKey: 'options.afi', value: 'AFIJO' },
{ labelKey: 'options.normal', value: 'NORMAL' },
],
defaultValue: 'AFIJO',
},
{
name: 'is_regime_change',
label: 'Es Cambio de Régimen',
labelKey: 'params.is_regime_change',
type: 'boolean',
defaultValue: false,
},
{
name: 'dateFormat',
label: 'Formato de Fecha',
labelKey: 'params.date_format',
type: 'select',
options: [
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
{ labelKey: 'options.date_dd_mm', value: 'dd/mm/yyyy' },
{ labelKey: 'options.date_mm_dd', value: 'mm/dd/yyyy' },
{ labelKey: 'options.date_iso', value: 'yyyy-mm-dd' }
],
defaultValue: 'dd/mm/yyyy'
}
@@ -204,7 +206,6 @@ export const tabSettings: Record<string, CsvUploadField[]> = {
export const catalogosConfig: CsvUploadItem[] = [
{
id: 'customs_brokers',
title: 'Agentes Aduanales',
icon: User,
modelTarget: 'CustomsBroker',
templateId: 'customs_brokers',
@@ -212,7 +213,6 @@ export const catalogosConfig: CsvUploadItem[] = [
},
{
id: 'clients_providers',
title: 'Clientes y Proveedores',
icon: Users,
modelTarget: 'ClientProvider',
templateId: 'clients_providers',
@@ -220,7 +220,6 @@ export const catalogosConfig: CsvUploadItem[] = [
},
{
id: 'exchange_rates',
title: 'Tipo de Cambios',
icon: DollarSign,
modelTarget: 'ExchangeRate',
templateId: 'exchange_rates',
@@ -228,7 +227,6 @@ export const catalogosConfig: CsvUploadItem[] = [
},
{
id: 'material_classes',
title: 'Clases de Materiales',
icon: Package,
modelTarget: 'MaterialClass',
templateId: 'material_classes',
@@ -236,7 +234,6 @@ export const catalogosConfig: CsvUploadItem[] = [
},
{
id: 'part_numbers',
title: 'Números de parte',
icon: Hash,
modelTarget: 'Part',
templateId: 'part_numbers',
@@ -244,7 +241,6 @@ export const catalogosConfig: CsvUploadItem[] = [
},
{
id: 'boms',
title: 'BOMs',
icon: Briefcase,
modelTarget: 'Bom',
templateId: 'boms',
@@ -253,30 +249,26 @@ export const catalogosConfig: CsvUploadItem[] = [
},
{
id: 'items',
title: 'Partidas (Permisos)',
icon: FileText,
group: 'Permisos',
group: 'permisos',
modelTarget: 'ItemPermission',
templateId: 'part_numbers'
},
{
id: 'headers',
title: 'Encabezados (Permisos)',
icon: FileText,
group: 'Permisos',
group: 'permisos',
modelTarget: 'HeaderPermission',
disabled: true,
},
{
id: 'historical_fractions',
title: 'Fracciones Históricas',
icon: Calendar,
modelTarget: 'HistoricalFraction',
disabled: true,
},
{
id: 'pedimentos',
title: 'Pedimentos',
icon: FileDigit,
modelTarget: 'Pedimento',
templateId: 'pedimentos',
@@ -288,7 +280,6 @@ export const catalogosConfig: CsvUploadItem[] = [
export const transportesConfig: CsvUploadItem[] = [
{
id: 'transporters',
title: 'Transportistas',
icon: Ship,
modelTarget: 'Transporter',
templateId: 'transporters',
@@ -296,7 +287,6 @@ export const transportesConfig: CsvUploadItem[] = [
},
{
id: 'transports',
title: 'Transportes',
icon: Truck,
modelTarget: 'Transport',
templateId: 'transports',
@@ -304,7 +294,6 @@ export const transportesConfig: CsvUploadItem[] = [
},
{
id: 'drivers',
title: 'Conductores',
icon: User,
modelTarget: 'Driver',
templateId: 'drivers',
@@ -312,7 +301,6 @@ export const transportesConfig: CsvUploadItem[] = [
},
{
id: 'trailers',
title: 'Trailers y Cajas',
icon: Container,
modelTarget: 'Trailer',
templateId: 'trailers',
@@ -326,27 +314,24 @@ export const importacionConfig: CsvUploadItem[] = [
// Impo Temp
{
id: 'imp_temp_header',
title: 'Encabezado',
icon: FileText,
group: 'Impo. Temp.',
group: 'impo_temp',
modelTarget: 'invoice_header',
templateId: 'imp_temp_header',
layoutModule: 'layouts_csv/facturas'
},
{
id: 'imp_temp_details',
title: 'Partidas',
icon: Package,
group: 'Impo. Temp.',
group: 'impo_temp',
modelTarget: 'invoice_details',
templateId: 'imp_temp_details',
layoutModule: 'layouts_csv/facturas'
},
{
id: 'imp_temp_series',
title: 'Series',
icon: Hash,
group: 'Impo. Temp.',
group: 'impo_temp',
modelTarget: 'invoice_series',
templateId: 'imp_temp_series',
layoutModule: 'layouts_csv/facturas'
@@ -354,27 +339,24 @@ export const importacionConfig: CsvUploadItem[] = [
// Impo Def
{
id: 'imp_def_header',
title: 'Encabezado',
icon: FileText,
group: 'Impo. Def.',
group: 'impo_def',
modelTarget: 'invoice_header',
templateId: 'imp_def_header',
layoutModule: 'layouts_csv/facturas'
},
{
id: 'imp_def_details',
title: 'Partidas',
icon: Package,
group: 'Impo. Def.',
group: 'impo_def',
modelTarget: 'invoice_details',
templateId: 'imp_def_details',
layoutModule: 'layouts_csv/facturas'
},
{
id: 'imp_def_series',
title: 'Series',
icon: Hash,
group: 'Impo. Def.',
group: 'impo_def',
modelTarget: 'invoice_series',
templateId: 'imp_def_series',
layoutModule: 'layouts_csv/facturas'
@@ -382,27 +364,24 @@ export const importacionConfig: CsvUploadItem[] = [
// Compras Mex
{
id: 'comp_mex_header',
title: 'Encabezado',
icon: FileText,
group: 'Compras Mex.',
group: 'cmex',
modelTarget: 'invoice_header',
templateId: 'cmex_header',
layoutModule: 'layouts_csv/facturas'
},
{
id: 'comp_mex_details',
title: 'Partidas',
icon: Package,
group: 'Compras Mex.',
group: 'cmex',
modelTarget: 'invoice_details',
templateId: 'cmex_details',
layoutModule: 'layouts_csv/facturas'
},
{
id: 'comp_mex_series',
title: 'Series',
icon: Hash,
group: 'Compras Mex.',
group: 'cmex',
modelTarget: 'invoice_series',
templateId: 'cmex_series',
layoutModule: 'layouts_csv/facturas'
@@ -415,70 +394,62 @@ export const exportacionConfig: CsvUploadItem[] = [
// Expo Def / Cam. Reg.
{
id: 'exp_def_header',
title: 'Encabezado',
icon: FileText,
group: 'Expo. Def./Cam. Reg.',
group: 'expo_def',
modelTarget: 'invoice_header',
templateId: 'exp_def_header',
layoutModule: 'layouts_csv/exportacion'
},
{
id: 'exp_def_details',
title: 'Partidas',
icon: Package,
group: 'Expo. Def./Cam. Reg.',
group: 'expo_def',
modelTarget: 'invoice_details',
templateId: 'exp_def_details',
layoutModule: 'layouts_csv/exportacion'
},
{
id: 'exp_def_series',
title: 'Series',
icon: Hash,
group: 'Expo. Def./Cam. Reg.',
group: 'expo_def',
modelTarget: 'invoice_series',
templateId: 'exp_def_series',
layoutModule: 'layouts_csv/exportacion'
},
{
id: 'exp_def_nodes',
title: 'NODES',
icon: Briefcase,
group: 'Expo. Def./Cam. Reg.',
group: 'expo_def',
modelTarget: 'Nodes',
disabled: true,
},
// Expo Rep
{
id: 'exp_rep_header',
title: 'Encabezado',
icon: FileText,
group: 'Expo. Rep.',
group: 'expo_rep',
modelTarget: 'InvoiceHeader',
disabled: true,
},
{
id: 'exp_rep_details',
title: 'Partidas',
icon: Package,
group: 'Expo. Rep.',
group: 'expo_rep',
modelTarget: 'InvoiceSalesDetails',
disabled: true,
},
{
id: 'exp_rep_series',
title: 'Series',
icon: Hash,
group: 'Expo. Rep.',
group: 'expo_rep',
modelTarget: 'InvoiceSeries',
disabled: true,
},
// Manifiesto
{
id: 'manifest_header',
title: 'Encabezado',
icon: FileText,
group: 'Manifiesto',
group: 'manifest',
modelTarget: 'Manifest',
disabled: true,
},

View File

@@ -0,0 +1,46 @@
/**
* Textos CSV desde `csv-upload-messages.{en,es}.json` (rama `csv_upload` extraída de messages/).
* Mantener en sync al editar `messages/*.json` (p. ej. volver a exportar la clave csv_upload).
*/
import enPack from './csv-upload-messages.en.json';
import esPack from './csv-upload-messages.es.json';
import { baseLocale, getLocale } from '$lib/paraglide/runtime';
type CsvUploadPack = Record<string, unknown>;
function pickPack(): CsvUploadPack {
let raw: string;
try {
raw = String(getLocale()).toLowerCase();
} catch {
raw = String(baseLocale).toLowerCase();
}
const root = raw.startsWith('en') ? enPack : esPack;
return root as CsvUploadPack;
}
function walk(root: Record<string, unknown>, keys: string[]): unknown {
let cur: unknown = root;
for (const k of keys) {
if (cur === null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[k];
}
return cur;
}
/** Navega `csv_upload.a.b.c` a partir de `subPath` = `a.b.c`. */
export function csvMsg(subPath: string): string {
const pack = pickPack();
if (!pack) return subPath;
const v = walk(pack as Record<string, unknown>, subPath.split('.'));
return typeof v === 'string' ? v : subPath;
}
/** Sustituye `{clave}` en el string del mensaje. */
export function csvFmt(subPath: string, vars: Record<string, string | number> = {}): string {
let s = csvMsg(subPath);
for (const [k, val] of Object.entries(vars)) {
s = s.replaceAll(`{${k}}`, String(val));
}
return s;
}

View File

@@ -0,0 +1,193 @@
{
"page_title": "CSV import",
"intro_help": "Left-click: upload CSV file. Right-click: download template.",
"tab_catalogos": "Catalogs",
"tab_transportes": "Transportation",
"tab_importacion": "Import",
"tab_exportacion": "Export",
"section_catalogs": "General Catalogs",
"section_transport": "Transportation",
"section_import": "Import operations",
"section_export": "Export operations",
"params_header": "Global parameters",
"config_prefix": "Settings",
"soon": "Coming soon",
"drop_here": "Drop the file!",
"groups": {
"permisos": "Permissions",
"impo_temp": "Temporary import",
"impo_def": "Definitive import",
"cmex": "Mexican purchases",
"expo_def": "Definitive export / regime change",
"expo_rep": "Export replenishment",
"manifest": "Manifest"
},
"items": {
"customs_brokers": "Customs Brokers",
"clients_providers": "Clients and Providers",
"exchange_rates": "Exchange Rates",
"material_classes": "Classes",
"part_numbers": "Parts",
"boms": "BOMs",
"items": "Lines (permissions)",
"headers": "Headers (permissions)",
"historical_fractions": "Historical tariff fractions",
"pedimentos": "Pedimentos",
"transporters": "Carriers",
"transports": "Vehicles",
"drivers": "Drivers",
"trailers": "Trailers",
"imp_temp_header": "Header",
"imp_temp_details": "Lines",
"imp_temp_series": "Serial numbers",
"imp_def_header": "Header",
"imp_def_details": "Lines",
"imp_def_series": "Serial numbers",
"comp_mex_header": "Header",
"comp_mex_details": "Lines",
"comp_mex_series": "Serial numbers",
"exp_def_header": "Header",
"exp_def_details": "Lines",
"exp_def_series": "Serial numbers",
"exp_def_nodes": "NODES",
"exp_rep_header": "Header",
"exp_rep_details": "Lines",
"exp_rep_series": "Serial numbers",
"manifest_header": "Header"
},
"params": {
"load_mode": "Load mode",
"date_format": "Date format",
"weight_unit": "Weight unit",
"autonumber_series": "Autonumber lines/series",
"load_subpartidas": "Load sub-lines",
"recalculate_pedimento_date": "Recalculate pedimento date",
"autonumber_remesas": "Autonumber consignments",
"recalculate_dates": "Recalculate dates",
"invoice_type": "Invoice type",
"is_regime_change": "Regime change"
},
"options": {
"update": "Update",
"replace": "Replace",
"yes": "Yes",
"no": "No",
"kgs": "Kilograms (kg)",
"lbs": "Pounds (lb)",
"date_dd_mm": "DD/MM/YYYY",
"date_mm_dd": "MM/DD/YYYY",
"date_iso": "YYYY-MM-DD",
"afi": "AFIJO",
"normal": "NORMAL"
},
"progress": {
"upload": "Uploading CSV file",
"scan": "Validating records on the server",
"commit": "Saving records to the database",
"upload_known": "Uploading file…",
"upload_unknown": "Uploading file (unknown size in browser)…",
"in_progress": "In progress…",
"resume_hint": "Resuming import saved in this tab…",
"rows_file": "File: ~{n} data row(s) — uploading (not yet validated on server)…",
"rows_scan": "Records processed: {current} / {total}",
"rows_commit": "Records saved: {current} / {total}",
"rows_commit_fallback": "Saving to database… ({current} / {total} using last known total)"
},
"toast": {
"invalid_csv": "Invalid format. Only .csv files are allowed.",
"download_loading": "Downloading template…",
"download_ok": "Template downloaded.",
"download_err": "Could not download the template.",
"upload_err": "Could not upload the file.",
"upload_err_generic": "Unexpected error uploading the file.",
"scan_done": "Scan complete. Review the results.",
"import_done": "Import completed. Review the record list.",
"import_maybe_done": "Import may have completed. Review the record list.",
"stale_job": "This import is no longer available (session expired or job removed). You can start a new upload.",
"poll_err": "Could not fetch status",
"commit_err": "Could not start import",
"scan_alt": "Scan finished. If you do not see the modal, check the record list.",
"finished_none": "No records inserted. Review the errors below.",
"commit_warning_ok": "{inserted} inserted, {updated} updated. {skipped} rejected.",
"commit_warning_none": "No records inserted or updated. {skipped} rejected.",
"success_counts": "Import completed: {msg}",
"warn_skipped": "{n} records rejected or skipped",
"error_processing": "Processing error: {msg}",
"n_inserted": "{n} inserted",
"n_updated": "{n} updated",
"err_fetch_scan_result": "Could not fetch the scan result. Check the results modal.",
"err_unknown": "Unknown error",
"err_processing_fallback": "Processing error. Check the modal or details."
},
"pending": {
"badge": "Pending",
"title": "Imports pending confirmation",
"description": "Scans ready to save to the database. Expired jobs disappear when you refresh.",
"refresh": "Refresh",
"empty": "No pending imports for this company.",
"checking": "Checking with the server…",
"total_rows": "Total rows",
"valid_rows": "Valid",
"resume": "Resume",
"remove": "Remove",
"profiles": {
"customs_brokers": "Customs Brokers",
"clients_providers": "Clients and Providers",
"exchange_rates": "Exchange Rates",
"pedimentos": "Pedimentos",
"material_classes": "Classes",
"vehicles": "Vehicles",
"drivers": "Drivers",
"trailers": "Trailers",
"transporters": "Carriers",
"part_numbers": "Parts",
"boms": "BOMs",
"exportacion": "Export operations",
"imports": "Import operations"
}
},
"config_empty": "No module-specific settings.",
"modal": {
"title_pending": "Import validation",
"title_success": "Import successful",
"title_warning": "Import with remarks",
"desc_pending": "Review the preliminary analysis before confirming.",
"desc_done": "The import process has finished.",
"total_rows": "Total rows",
"valid_rows": "Valid",
"invalid_rows": "Invalid",
"errors": "Errors",
"errors_heading": "Scan errors (fix in your CSV)",
"errors_badge": "{shown} of {total} error(s)",
"errors_truncated": "Download the CSV to see all errors.",
"errors_missing_detail": "{count} row(s) had errors but details are not available. Ensure the server is up to date and upload again.",
"scan_ok_title": "File validated successfully",
"scan_ok_body": "All rows look correct and ready to import.",
"scan_problems_title": "Problems found in the file",
"scan_problems_body": "Fix the issues listed below in your CSV and upload again, or confirm to import only valid rows (invalid rows will be skipped).",
"inserted": "Inserted",
"updated": "Updated",
"rejected": "Rejected",
"rejected_hint": "See line-by-line detail in the table below.",
"ref_gaps_title": "Reference gaps (FK / catalogs)",
"ref_gaps_body": "There are {n} critical reference gap(s). Review catalogs and rejected rows before retrying.",
"ref_state_title": "Reference state",
"ref_state_ok": "References ready to operate (no critical gaps reported).",
"ref_state_other": "No numeric gaps; review the server message if applicable.",
"skipped_reasons_heading": "Rejection reasons summary",
"commit_errors_heading": "Error detail",
"rows_badge": "{n} rows",
"importing_records": "Importing records…",
"cancel_operation": "Cancel",
"processing": "Processing…",
"confirm_load": "Confirm import",
"close": "Close",
"th_line": "Line",
"th_column": "Column",
"th_message": "Message",
"th_solution": "Solution",
"th_reference": "Reference",
"th_reason": "Reason",
"download_csv": "Download CSV"
}
}

View File

@@ -0,0 +1,193 @@
{
"page_title": "Importación CSV",
"intro_help": "Clic izquierdo: cargar archivo CSV. Clic derecho: descargar plantilla.",
"tab_catalogos": "Catálogos",
"tab_transportes": "Transportes",
"tab_importacion": "Importación",
"tab_exportacion": "Exportación",
"section_catalogs": "Catalogos Generales",
"section_transport": "Transportes",
"section_import": "Operaciones de importación",
"section_export": "Operaciones de exportación",
"params_header": "Parámetros globales",
"config_prefix": "Configuración",
"soon": "Próximamente",
"drop_here": "¡Suelta el archivo!",
"groups": {
"permisos": "Permisos",
"impo_temp": "Impo. temp.",
"impo_def": "Impo. def.",
"cmex": "Compras mex.",
"expo_def": "Expo. def./Cam. reg.",
"expo_rep": "Expo. rep.",
"manifest": "Manifiesto"
},
"items": {
"customs_brokers": "Agentes Aduanales",
"clients_providers": "Clientes y Proveedores",
"exchange_rates": "Tipos de cambio",
"material_classes": "Clases",
"part_numbers": "Partes",
"boms": "BOMs",
"items": "Partidas (permisos)",
"headers": "Encabezados (permisos)",
"historical_fractions": "Fracciones históricas",
"pedimentos": "Pedimentos",
"transporters": "Transportistas",
"transports": "Vehículos",
"drivers": "Conductores",
"trailers": "Trailers",
"imp_temp_header": "Encabezado",
"imp_temp_details": "Partidas",
"imp_temp_series": "Series",
"imp_def_header": "Encabezado",
"imp_def_details": "Partidas",
"imp_def_series": "Series",
"comp_mex_header": "Encabezado",
"comp_mex_details": "Partidas",
"comp_mex_series": "Series",
"exp_def_header": "Encabezado",
"exp_def_details": "Partidas",
"exp_def_series": "Series",
"exp_def_nodes": "NODES",
"exp_rep_header": "Encabezado",
"exp_rep_details": "Partidas",
"exp_rep_series": "Series",
"manifest_header": "Encabezado"
},
"params": {
"load_mode": "Modo de carga",
"date_format": "Formato de fecha",
"weight_unit": "Unidad de peso",
"autonumber_series": "Autonumerar partidas/series",
"load_subpartidas": "Levantar subpartidas",
"recalculate_pedimento_date": "Recalcular fecha pedimento",
"autonumber_remesas": "Autonumerar remesas",
"recalculate_dates": "Recalcular fechas",
"invoice_type": "Tipo de factura",
"is_regime_change": "Es cambio de régimen"
},
"options": {
"update": "Actualizar",
"replace": "Reemplazar",
"yes": "Sí",
"no": "No",
"kgs": "Kilos (kg)",
"lbs": "Libras (lb)",
"date_dd_mm": "DD/MM/YYYY",
"date_mm_dd": "MM/DD/YYYY",
"date_iso": "YYYY-MM-DD",
"afi": "AFIJO",
"normal": "NORMAL"
},
"progress": {
"upload": "Subiendo archivo CSV",
"scan": "Validando registros en el servidor",
"commit": "Grabando registros en base de datos",
"upload_known": "Subiendo archivo…",
"upload_unknown": "Subiendo archivo (tamaño desconocido en el navegador)…",
"in_progress": "En proceso…",
"resume_hint": "Reanudando la importación guardada en esta pestaña…",
"rows_file": "Archivo: ~{n} fila(s) de datos — subiendo (aún no se validan registros en servidor)…",
"rows_scan": "Registros procesados: {current} / {total}",
"rows_commit": "Registros grabados: {current} / {total}",
"rows_commit_fallback": "Grabando en base de datos… ({current} / {total} según último total conocido)"
},
"toast": {
"invalid_csv": "Formato inválido. Solo se permiten archivos .csv",
"download_loading": "Descargando plantilla…",
"download_ok": "Plantilla descargada.",
"download_err": "Error al descargar la plantilla",
"upload_err": "Error al subir el archivo",
"upload_err_generic": "Error inesperado al subir el archivo",
"scan_done": "Escaneo completado. Revisa los resultados.",
"import_done": "Importación completada. Revisa el listado de registros.",
"import_maybe_done": "La importación pudo completarse. Revisa el listado de registros.",
"stale_job": "Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.",
"poll_err": "Error al consultar el estado",
"commit_err": "Error al iniciar la importación",
"scan_alt": "El escaneo terminó. Si no ves el modal, revisa el listado de registros.",
"finished_none": "No se insertaron registros. Revisa los errores a continuación.",
"commit_warning_ok": "{inserted} insertados, {updated} actualizados. {skipped} rechazados.",
"commit_warning_none": "No se insertaron ni actualizaron registros. {skipped} rechazados.",
"success_counts": "Importación completada: {msg}",
"warn_skipped": "{n} registros fueron rechazados u omitidos",
"error_processing": "Error en el procesamiento: {msg}",
"n_inserted": "{n} insertados",
"n_updated": "{n} actualizados",
"err_fetch_scan_result": "Error al obtener el resultado. Revisa el modal de resultados.",
"err_unknown": "Error desconocido",
"err_processing_fallback": "Error en el procesamiento. Revisa el modal o los detalles."
},
"pending": {
"badge": "Pendientes",
"title": "Importaciones pendientes de confirmar",
"description": "Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al actualizar.",
"refresh": "Actualizar",
"empty": "No hay importaciones pendientes para esta empresa.",
"checking": "Comprobando con el servidor…",
"total_rows": "Total filas",
"valid_rows": "Válidas",
"resume": "Reanudar",
"remove": "Quitar",
"profiles": {
"customs_brokers": "Agentes Aduanales",
"clients_providers": "Clientes y Proveedores",
"exchange_rates": "Tipos de cambio",
"pedimentos": "Pedimentos",
"material_classes": "Clases",
"vehicles": "Vehículos",
"drivers": "Conductores",
"trailers": "Trailers",
"transporters": "Transportistas",
"part_numbers": "Partes",
"boms": "BOMs",
"exportacion": "Exportación (operaciones)",
"imports": "Importación (operaciones)"
}
},
"config_empty": "No hay configuraciones específicas para este módulo.",
"modal": {
"title_pending": "Validación de importación",
"title_success": "Importación exitosa",
"title_warning": "Importación con observaciones",
"desc_pending": "Revise el análisis preliminar antes de confirmar la carga de datos.",
"desc_done": "El proceso de importación ha finalizado.",
"total_rows": "Total filas",
"valid_rows": "Válidos",
"invalid_rows": "Inválidos",
"errors": "Errores",
"errors_heading": "Detalle de errores (para corregir en el CSV)",
"errors_badge": "{shown} de {total} error(es)",
"errors_truncated": "Para consultar el resto de errores, descargue el CSV.",
"errors_missing_detail": "Se detectaron {count} fila(s) con errores pero el detalle no está disponible. Asegúrese de que el servidor esté actualizado y vuelva a subir el archivo.",
"scan_ok_title": "Archivo validado correctamente",
"scan_ok_body": "Todos los registros parecen correctos y listos para importar.",
"scan_problems_title": "Se detectaron problemas en el archivo",
"scan_problems_body": "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).",
"inserted": "Insertados",
"updated": "Actualizados",
"rejected": "Rechazados",
"rejected_hint": "Revisa el detalle por línea en la tabla inferior.",
"ref_gaps_title": "Brechas de referencia (FK / catálogos)",
"ref_gaps_body": "Hay {n} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de filas rechazadas antes de reintentar.",
"ref_state_title": "Estado de referencias",
"ref_state_ok": "Referencias listas para operar (sin brechas críticas reportadas).",
"ref_state_other": "Sin brechas numéricas; revisa el mensaje del servidor si aplica.",
"skipped_reasons_heading": "Resumen de motivos de rechazo",
"commit_errors_heading": "Detalle de errores",
"rows_badge": "{n} filas",
"importing_records": "Importando registros…",
"cancel_operation": "Cancelar",
"processing": "Procesando…",
"confirm_load": "Confirmar carga",
"close": "Cerrar",
"th_line": "Línea",
"th_column": "Columna",
"th_message": "Mensaje",
"th_solution": "Solución",
"th_reference": "Referencia",
"th_reason": "Motivo",
"download_csv": "Descargar CSV"
}
}

View File

@@ -39,6 +39,7 @@
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 {
@@ -240,11 +241,11 @@
const csvProgressStepTitle = $derived.by(() => {
switch (csvProgressPhase) {
case 'upload':
return 'Subiendo archivo CSV';
return csvMsg('progress.upload');
case 'scan':
return 'Validando registros en el servidor';
return csvMsg('progress.scan');
case 'commit':
return 'Grabando registros en base de datos';
return csvMsg('progress.commit');
default:
return '';
}
@@ -261,17 +262,26 @@
switch (csvProgressPhase) {
case 'upload':
if (csvImportRowTotal > 0) {
return `Archivo: ~${csvImportRowTotal} fila(s) de datos — subiendo (aún no se validan registros en servidor)…`;
return csvFmt('progress.rows_file', { n: csvImportRowTotal });
}
return uploadLengthComputable
? 'Subiendo archivo…'
: 'Subiendo archivo (tamaño desconocido en el navegador)…';
? csvMsg('progress.upload_known')
: csvMsg('progress.upload_unknown');
case 'scan':
return `Registros procesados: ${Math.min(scanProgressCurrent, denom)} / ${denom}`;
return csvFmt('progress.rows_scan', {
current: Math.min(scanProgressCurrent, denom),
total: denom
});
case 'commit':
return scanProgressTotal > 0
? `Registros grabados: ${Math.min(scanProgressCurrent, denom)} / ${denom}`
: `Grabando en base de datos… (${Math.min(scanProgressCurrent, denom)} / ${denom} según último total conocido)`;
? 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 '';
}
@@ -306,7 +316,7 @@
const csvFooterAriaValueText = $derived(
csvFooterProgressIndeterminate
? `${csvProgressStepTitle}. ${csvProgressDetailLine || 'En proceso…'}`
? `${csvProgressStepTitle}. ${csvProgressDetailLine || csvMsg('progress.in_progress')}`
: csvProgressDetailLine
? `${csvProgressStepTitle}, ${csvFooterPercentLabel}. ${csvProgressDetailLine}`
: `${csvProgressStepTitle}, ${csvFooterPercentLabel}`
@@ -591,7 +601,7 @@
csvResumeOverlayHint = false;
skipScanCompleteToastOnce = false;
scanPhaseJobId = null;
currentImportLabel = (config.title && String(config.title).trim()) || null;
currentImportLabel = csvMsg(`items.${config.id}`);
const onCsvFileUploadProgress = (e: { loaded: number; total: number }) => {
if (e.total > 0) {
@@ -626,12 +636,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
@@ -646,12 +656,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
@@ -673,12 +683,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
@@ -698,12 +708,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
@@ -721,12 +731,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
@@ -744,12 +754,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
@@ -764,12 +774,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
@@ -787,12 +797,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
@@ -810,12 +820,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
@@ -834,12 +844,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
@@ -854,12 +864,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
@@ -888,12 +898,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
return;
@@ -915,12 +925,12 @@
currentJobId = res.data.job_id;
beginCsvScanAfterUpload();
} else {
toast.error(res.error || 'Error al subir el archivo');
toast.error(res.error || csvMsg('toast.upload_err'));
isUploading = false;
}
} catch (e) {
console.error('Upload exception', e);
toast.error('Error inesperado al subir el archivo');
toast.error(csvMsg('toast.upload_err_generic'));
isUploading = false;
}
}
@@ -943,11 +953,9 @@
const errMsg = res.error || '';
if (isStaleImportJobError(res.status, errMsg)) {
removePendingLinkedToCurrentFlow();
toast.info(
'Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.'
);
toast.info(csvMsg('toast.stale_job'));
} else {
toast.error(errMsg || 'Error al consultar el estado');
toast.error(errMsg || csvMsg('toast.poll_err'));
}
isUploading = false;
clearCsvImportSession();
@@ -978,7 +986,7 @@
if (skipScanCompleteToastOnce) {
skipScanCompleteToastOnce = false;
} else {
toast.success('Escaneo completado. Revisa los resultados.');
toast.success(csvMsg('toast.scan_done'));
}
isUploading = false;
} else if (res.data?.status === 'failed' || res.data?.status === 'FAILURE') {
@@ -996,7 +1004,7 @@
commitResults = parsedCommit;
showResultModal = true;
finalizeCommitAndClearPending();
toast.success('Importación completada. Revisa el listado de registros.');
toast.success(csvMsg('toast.import_done'));
isUploading = false;
clearCsvImportSession();
currentJobId = null;
@@ -1026,7 +1034,7 @@
if (skipScanCompleteToastOnce) {
skipScanCompleteToastOnce = false;
} else {
toast.success('Escaneo completado. Revisa los resultados.');
toast.success(csvMsg('toast.scan_done'));
}
isUploading = false;
} else {
@@ -1036,30 +1044,32 @@
(errRaw.includes('waiting_confirmation') || (errRaw.includes('total_rows') && errRaw.includes('job_id')));
if (looksLikeScanInError) {
removePendingLinkedToCurrentFlow();
toast.info('El escaneo terminó. Si no ves el modal, revisa el listado de registros.');
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')
? 'La importación pudo completarse. Revisa el listado de registros.'
? maybeDoneMsg
: errRaw.includes("'status'") && errRaw.includes('waiting_confirmation')
? 'Error al obtener el resultado. Revisa el modal de resultados.'
? csvMsg('toast.err_fetch_scan_result')
: errRaw;
} else if (typeof errRaw === 'object' && errRaw !== null) {
errText = (errRaw as { message?: string })?.message || 'Error en el procesamiento. Revisa el modal o los detalles.';
errText =
(errRaw as { message?: string })?.message || csvMsg('toast.err_processing_fallback');
} else {
errText = 'Error desconocido';
errText = csvMsg('toast.err_unknown');
}
// No mostrar como error si el mensaje indica éxito
if (errText.includes('pudo completarse') || errText.includes('Revisa el listado')) {
if (errText === maybeDoneMsg || errText.includes(maybeDoneMsg)) {
toast.success(errText);
} else {
toast.error('Error en el procesamiento: ' + errText);
toast.error(csvFmt('toast.error_processing', { msg: errText }));
}
isUploading = false;
removePendingLinkedToCurrentFlow();
@@ -1082,12 +1092,16 @@
if (totalOk === 0) {
toast.error(
backendMessage ||
`No se insertaron ni actualizaron registros. ${totalSkipped} fueron rechazados.`
csvFmt('toast.commit_warning_none', { skipped: totalSkipped })
);
} else {
toast.warning(
backendMessage ||
`Se aplicaron ${totalOk} registros (${inserted} insertados, ${updated} actualizados). ${totalSkipped} rechazados.`
csvFmt('toast.commit_warning_ok', {
inserted,
updated,
skipped: totalSkipped
})
);
}
finalizeCommitAndClearPending();
@@ -1100,15 +1114,15 @@
const totalSkipped = totalSkippedFromCommit(res.data as Record<string, unknown>);
if (inserted > 0 || updated > 0) {
const parts = [];
if (inserted > 0) parts.push(`${inserted} insertados`);
if (updated > 0) parts.push(`${updated} actualizados`);
toast.success(`Importación completada: ${parts.join(', ')}`);
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(`${totalSkipped} registros fueron rechazados u omitidos`);
toast.warning(csvFmt('toast.warn_skipped', { n: totalSkipped }));
}
} else {
toast.error('No se insertaron registros. Revisa los errores a continuación.');
toast.error(csvMsg('toast.finished_none'));
}
finalizeCommitAndClearPending();
isUploading = false;
@@ -1136,26 +1150,26 @@
<!-- 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">Importación Masiva de Datos (CSV)</h1>
<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">
Click izquierdo: cargar archivo CSV. Click derecho: descargar estructura (plantilla).
{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">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.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">Catálogos Generales</h2>
<h2 class="text-lg font-medium tracking-tight">{csvMsg('section_catalogs')}</h2>
</div>
<UploadLauncherGrid
items={catalogosConfig}
@@ -1167,7 +1181,7 @@
<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">Logística y Transporte</h2>
<h2 class="text-lg font-medium tracking-tight">{csvMsg('section_transport')}</h2>
</div>
<UploadLauncherGrid
items={transportesConfig}
@@ -1179,7 +1193,7 @@
<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">Operaciones de Importación</h2>
<h2 class="text-lg font-medium tracking-tight">{csvMsg('section_import')}</h2>
</div>
<UploadLauncherGrid
items={importacionConfig}
@@ -1190,7 +1204,7 @@
<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>
<h2 class="text-lg font-medium tracking-tight">{csvMsg('section_export')}</h2>
</div>
<UploadLauncherGrid
items={exportacionConfig}
@@ -1224,7 +1238,7 @@
aria-busy="true"
>
{#if csvResumeOverlayHint}
<p class="text-xs text-muted-foreground">Reanudando la importación guardada en esta pestaña…</p>
<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>
@@ -1340,7 +1354,7 @@
pollStatus();
}
} catch (err) {
toast.error('Error al iniciar la importación');
toast.error(csvMsg('toast.commit_err'));
isUploading = false;
}
}}