checkpoint

This commit is contained in:
2026-04-01 13:40:46 -06:00
parent a08a62bc31
commit e8b4cbd377
37 changed files with 551 additions and 248 deletions

View File

@@ -2,15 +2,19 @@
import { Label } from '$lib/components/ui/label/index.js';
import { Settings2 } from 'lucide-svelte';
import { globalCsvParams, tabSettings, type CsvUploadField } from '$lib/config/csv-upload';
import { cn } from '$lib/utils';
let {
globalSettings = $bindable(),
activeTab,
tabSettingsValues = $bindable()
tabSettingsValues = $bindable(),
embedded = false
}: {
globalSettings: Record<string, string>;
activeTab: string;
tabSettingsValues: Record<string, any>;
/** Si true, el padre aporta el contenedor fijo (p. ej. pie apilado con progreso CSV). */
embedded?: boolean;
} = $props();
const globalParamNames = $derived(new Set(globalCsvParams.map((p) => p.name)));
@@ -27,7 +31,12 @@
<!-- Fixed bar: z-index below help bubble (help uses z-50) so help stays on top -->
<div
class="fixed right-0 bottom-0 left-0 z-40 ml-[calc(var(--sidebar-width))] border-t bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
class={cn(
'w-full bg-background/95 supports-[backdrop-filter]:bg-background/80',
embedded
? ''
: 'fixed right-0 bottom-0 left-0 z-40 ml-[calc(var(--sidebar-width))] border-t shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))]'
)}
>
<div class="mx-auto max-w-[1400px] px-4 py-3">
<div class="flex flex-wrap items-end gap-6">

View File

@@ -11,6 +11,11 @@
} from 'lucide-svelte';
import { tick } from 'svelte';
import { api } from '$lib/api';
import {
totalSkippedFromCommit,
criticalReferenceGaps,
referenceStateReady
} from '$lib/csv-import-commit-metrics';
let {
open = $bindable(false),
@@ -50,29 +55,33 @@
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 scanErrorsShown = $derived(Array.isArray(scanResults?.errors) ? scanResults.errors.length : 0);
let scanErrorsTotal = $derived(scanResults?.error_count || 0);
let scanErrorsTruncated = $derived(scanErrorsShown > 0 && scanErrorsShown < scanErrorsTotal);
let totalSkipped = $derived(
(commitResults?.skipped_invalid || 0) +
(commitResults?.skipped_missing_fk || 0) +
(commitResults?.skipped_missing_invoice || 0) +
(commitResults?.skipped_duplicate || 0)
let scanErrorSummary = $derived(
Array.isArray(scanResults?.error_summary) ? scanResults.error_summary : []
);
let insertedCount = $derived(commitResults?.inserted || 0);
let updatedCount = $derived(commitResults?.updated || 0);
// Algunos módulos usan `inserted` solo para altas y `updated` para modo "actualizar".
// Para la UI, reflejamos "cambios positivos" como insertados + actualizados.
let insertedOrUpdatedCount = $derived(insertedCount + updatedCount);
let totalSkipped = $derived(totalSkippedFromCommit(commitResults));
let refGaps = $derived(criticalReferenceGaps(commitResults));
let refReady = $derived(referenceStateReady(commitResults));
let insertedCount = $derived(Number(commitResults?.inserted) || 0);
let updatedCount = $derived(Number(commitResults?.updated) || 0);
/** Cabecera: errores de escaneo (solo pendiente) o observaciones de commit. */
let hasErrors = $derived(
(isPending && (scanResults?.error_count ?? 0) > 0) ||
(isFinished &&
(totalSkipped > 0 ||
refGaps > 0 ||
commitResults?.status === 'warning'))
);
let commitSkippedSummary = $derived(
Array.isArray(commitResults?.skipped_summary) ? commitResults.skipped_summary : []
);
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
@@ -298,6 +307,24 @@
</div>
</div>
{#if scanResults.message}
<p class="mb-4 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm text-foreground">
{scanResults.message}
</p>
{/if}
{#if scanErrorSummary.length > 0}
<div class="mb-4 flex flex-wrap gap-2">
{#each scanErrorSummary as item}
<span
class="inline-flex items-center rounded-full border border-border bg-secondary/60 px-2.5 py-0.5 text-xs text-secondary-foreground"
>
{(item as { reason?: string; count?: number }).reason ?? '—'} · {(item as { count?: number })
.count ?? 0}
</span>
{/each}
</div>
{/if}
{#if scanResults.error_count > 0}
<div
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3 mb-4"
@@ -382,48 +409,100 @@
<!-- 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="grid grid-cols-1 gap-4 sm:grid-cols-3">
<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"
class="flex flex-col items-center justify-center rounded-lg border border-green-100 bg-green-50/50 p-4 text-center shadow-sm dark:border-green-900/30 dark:bg-green-900/10"
>
<div class="flex items-center gap-2 mb-1">
<CheckCircle2 class="w-4 h-4 text-green-600 dark:text-green-400" />
<div class="mb-1 flex items-center gap-2">
<CheckCircle2 class="h-4 w-4 text-green-600 dark:text-green-400" />
<span
class="text-green-600 dark:text-green-400 text-xs uppercase font-bold tracking-wider"
class="text-xs font-bold uppercase tracking-wider text-green-600 dark:text-green-400"
>Insertados</span
>
</div>
<span class="text-3xl font-bold text-green-700 dark:text-green-300"
>{insertedOrUpdatedCount}</span
>
{#if updatedCount > 0}
<p class="text-xs text-green-700 dark:text-green-300 mt-2">
{insertedCount} insertados, {updatedCount} actualizados
</p>
{/if}
<span class="text-3xl font-bold text-green-700 dark:text-green-300">{insertedCount}</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"
class="flex flex-col items-center justify-center rounded-lg border border-green-100 bg-green-50/50 p-4 text-center shadow-sm dark:border-green-900/30 dark:bg-green-900/10"
>
<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"
<div class="mb-1 flex items-center gap-2">
<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
>
</div>
<span class="text-3xl font-bold text-green-700 dark:text-green-300">{updatedCount}</span>
</div>
<div
class="flex flex-col items-center justify-center rounded-lg border border-destructive/10 bg-destructive/5 p-4 text-center shadow-sm"
>
<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
>
</div>
<span class="text-3xl font-bold text-destructive">{totalSkipped}</span>
{#if totalSkipped > 0 && commitResults.skipped_details && commitResults.skipped_details.length > 0}
<p class="text-xs text-muted-foreground mt-2">
<p class="mt-2 text-xs text-muted-foreground">
Revisa el detalle por línea en la tabla inferior.
</p>
{/if}
</div>
</div>
<div
class="flex items-start gap-3 rounded-md border p-3 text-sm {refGaps > 0
? 'border-amber-500/40 bg-amber-500/10'
: 'border-border bg-muted/30'}"
>
{#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="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.
</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="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.'}
</p>
</div>
{/if}
</div>
{#if commitResults.message}
<p class="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm text-foreground">
{commitResults.message}
</p>
{/if}
{#if commitSkippedSummary.length > 0}
<div>
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Resumen de motivos de rechazo
</p>
<div class="flex flex-wrap gap-2">
{#each commitSkippedSummary as item}
<span
class="inline-flex items-center rounded-full border border-border bg-secondary/60 px-2.5 py-0.5 text-xs text-secondary-foreground"
>
{(item as { reason?: string; count?: number }).reason ?? '—'} · {(item as {
count?: number;
}).count ?? 0}
</span>
{/each}
</div>
</div>
{/if}
<!-- Error Details Table -->
{#if commitResults.skipped_details && commitResults.skipped_details.length > 0}
<div id="detalle-errores-import" class="border rounded-lg overflow-hidden mt-2 shadow-sm">
@@ -466,7 +545,7 @@
<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>
<td class="px-4 py-2 text-destructive">{detail.reason ?? '-'}</td>
<td class="px-4 py-2 text-muted-foreground whitespace-pre-wrap">{detail.solution || '-'}</td>
</tr>
{/each}

View File

@@ -8,12 +8,17 @@
let {
items,
onUpload
onUpload,
busy = false
}: {
items: CsvUploadItem[];
onUpload: (file: File, config: CsvUploadItem) => void;
/** Bloquea nuevas cargas (p. ej. importación en curso sin overlay). */
busy?: boolean;
} = $props();
const gridLocked = $derived(busy);
let dragOverId = $state<string | null>(null);
// Group items
@@ -34,7 +39,7 @@
});
function handleDragEnter(e: DragEvent, id: string, disabled?: boolean) {
if (disabled) return;
if (disabled || gridLocked) return;
e.preventDefault();
e.stopPropagation();
dragOverId = id;
@@ -47,7 +52,7 @@
}
function handleDragOver(e: DragEvent, disabled?: boolean) {
if (disabled) return;
if (disabled || gridLocked) return;
e.preventDefault();
e.stopPropagation();
dragOverId = null;
@@ -65,7 +70,7 @@
}
function handleDrop(e: DragEvent, item: CsvUploadItem) {
if (item.disabled) return;
if (item.disabled || gridLocked) return;
e.preventDefault();
e.stopPropagation();
dragOverId = null;
@@ -76,12 +81,13 @@
}
function handleClick(id: string, disabled?: boolean) {
if (disabled) return;
if (disabled || gridLocked) return;
const input = document.getElementById(`file-input-${id}`) as HTMLInputElement;
if (input) input.click();
}
function handleFileChange(e: Event, item: CsvUploadItem) {
if (gridLocked) return;
const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
validateAndUpload(target.files[0], item);
@@ -90,7 +96,7 @@
}
async function handleContextMenu(e: MouseEvent, item: CsvUploadItem) {
if (item.disabled) {
if (item.disabled || gridLocked) {
e.preventDefault();
return;
}
@@ -117,25 +123,30 @@
}
</script>
<div class="flex flex-col gap-6 select-none">
<div
class="flex flex-col gap-6 select-none transition-opacity"
class:opacity-60={gridLocked}
aria-busy={gridLocked ? true : undefined}
>
{#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}
<div
class={cn(
'relative group transition-all duration-200 ease-in-out transform',
item.disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
!item.disabled && dragOverId === item.id ? 'scale-105' : ''
item.disabled || gridLocked ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
!item.disabled && !gridLocked && dragOverId === item.id ? 'scale-105' : ''
)}
ondragenter={(e) => handleDragEnter(e, item.id, item.disabled)}
ondragenter={(e) => handleDragEnter(e, item.id, item.disabled || gridLocked)}
ondragleave={handleDragLeave}
ondragover={(e) => handleDragOver(e, item.disabled)}
ondragover={(e) => handleDragOver(e, item.disabled || gridLocked)}
ondrop={(e) => handleDrop(e, item)}
oncontextmenu={(e) => handleContextMenu(e, item)}
role="button"
tabindex={item.disabled ? -1 : 0}
onclick={() => handleClick(item.id, item.disabled)}
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}
tabindex={item.disabled || gridLocked ? -1 : 0}
onclick={() => handleClick(item.id, item.disabled || gridLocked)}
onkeydown={(e) =>
!item.disabled && !gridLocked && e.key === 'Enter' && handleClick(item.id)}
>
<input
type="file"
@@ -143,14 +154,14 @@
class="hidden"
accept=".csv"
onchange={(e) => handleFileChange(e, item)}
disabled={item.disabled}
disabled={item.disabled || gridLocked}
/>
<Card.Root
class={cn(
'h-full border-2 border-dashed border-transparent transition-colors w-full text-left relative overflow-hidden',
!item.disabled && 'hover:border-primary/50 hover:shadow-md',
!item.disabled && dragOverId === item.id
!item.disabled && !gridLocked && 'hover:border-primary/50 hover:shadow-md',
!item.disabled && !gridLocked && dragOverId === item.id
? 'border-primary bg-primary/5 shadow-xl ring-2 ring-primary ring-offset-2'
: ''
)}
@@ -168,7 +179,7 @@
<Card.Content
class="flex flex-col items-center justify-center p-6 gap-3 text-center h-full relative z-10"
>
{#if !item.disabled && dragOverId === item.id}
{#if !item.disabled && !gridLocked && dragOverId === item.id}
<div class="animate-bounce">
<UploadCloud class="h-8 w-8 text-primary" />
</div>
@@ -177,7 +188,7 @@
<div
class={cn(
'p-3 bg-muted rounded-full transition-transform duration-200',
!item.disabled && 'group-hover:scale-110'
!item.disabled && !gridLocked && 'group-hover:scale-110'
)}
>
<item.icon class="h-6 w-6 text-primary" />
@@ -201,18 +212,19 @@
<div
class={cn(
'relative group transition-all duration-200 ease-in-out transform',
item.disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
!item.disabled && dragOverId === item.id ? 'scale-105' : ''
item.disabled || gridLocked ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
!item.disabled && !gridLocked && dragOverId === item.id ? 'scale-105' : ''
)}
ondragenter={(e) => handleDragEnter(e, item.id, item.disabled)}
ondragenter={(e) => handleDragEnter(e, item.id, item.disabled || gridLocked)}
ondragleave={handleDragLeave}
ondragover={(e) => handleDragOver(e, item.disabled)}
ondragover={(e) => handleDragOver(e, item.disabled || gridLocked)}
ondrop={(e) => handleDrop(e, item)}
oncontextmenu={(e) => handleContextMenu(e, item)}
role="button"
tabindex={item.disabled ? -1 : 0}
onclick={() => handleClick(item.id, item.disabled)}
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}
tabindex={item.disabled || gridLocked ? -1 : 0}
onclick={() => handleClick(item.id, item.disabled || gridLocked)}
onkeydown={(e) =>
!item.disabled && !gridLocked && e.key === 'Enter' && handleClick(item.id)}
>
<input
type="file"
@@ -220,14 +232,14 @@
class="hidden"
accept=".csv"
onchange={(e) => handleFileChange(e, item)}
disabled={item.disabled}
disabled={item.disabled || gridLocked}
/>
<Card.Root
class={cn(
'h-full border-2 border-dashed border-transparent transition-colors w-full text-left relative overflow-hidden',
!item.disabled && 'hover:border-primary/50 hover:shadow-md',
!item.disabled && dragOverId === item.id
!item.disabled && !gridLocked && 'hover:border-primary/50 hover:shadow-md',
!item.disabled && !gridLocked && dragOverId === item.id
? 'border-primary bg-primary/5 shadow-xl ring-2 ring-primary ring-offset-2'
: ''
)}
@@ -247,7 +259,7 @@
<Card.Content
class="flex flex-col items-center justify-center p-6 gap-3 text-center h-full relative z-10"
>
{#if !item.disabled && dragOverId === item.id}
{#if !item.disabled && !gridLocked && dragOverId === item.id}
<div class="animate-bounce">
<UploadCloud class="h-8 w-8 text-primary" />
</div>
@@ -256,7 +268,7 @@
<div
class={cn(
'p-3 bg-muted rounded-full transition-transform duration-200',
!item.disabled && 'group-hover:scale-110'
!item.disabled && !gridLocked && 'group-hover:scale-110'
)}
>
<item.icon class="h-6 w-6 text-primary" />

View File

@@ -0,0 +1,36 @@
/**
* Métricas homogéneas post-commit CSV (contrato tipo pedimentos + extras p. ej. facturas).
*/
export function totalSkippedFromCommit(cr: Record<string, unknown> | null | undefined): number {
if (!cr || typeof cr !== 'object') return 0;
const n = (k: string) => {
const v = (cr as Record<string, unknown>)[k];
if (typeof v === 'number' && Number.isFinite(v)) return v;
if (typeof v === 'string' && v.trim() !== '') return parseInt(v, 10) || 0;
return 0;
};
return (
n('skipped_invalid') +
n('skipped_missing_fk') +
n('skipped_missing_invoice') +
n('skipped_duplicate')
);
}
export function criticalReferenceGaps(cr: Record<string, unknown> | null | undefined): number {
if (!cr || typeof cr !== 'object') return 0;
const v = (cr as Record<string, unknown>).critical_reference_gaps;
if (typeof v === 'number' && Number.isFinite(v)) return v;
return 0;
}
export function referenceStateReady(cr: Record<string, unknown> | null | undefined): boolean {
if (!cr || typeof cr !== 'object') return true;
if ('reference_state_ready' in cr) {
const v = (cr as Record<string, unknown>).reference_state_ready;
if (typeof v === 'boolean') return v;
if (v === 'True' || v === 'true') return true;
if (v === 'False' || v === 'false') return false;
}
return criticalReferenceGaps(cr) === 0;
}

View File

@@ -0,0 +1,15 @@
/**
* Cuenta filas de datos (excluye 1 línea de encabezado) en un CSV local.
* Asume primera línea no vacía = encabezado.
*/
export async function countCsvDataRows(file: File): Promise<number> {
const text = await file.text();
if (!text.trim()) return 0;
const lines = text.split(/\r\n|\r|\n/);
let nonEmpty = 0;
for (const line of lines) {
if (line.trim().length > 0) nonEmpty += 1;
}
if (nonEmpty <= 1) return 0;
return nonEmpty - 1;
}

View File

@@ -32,6 +32,8 @@
type CsvImportPendingEntry
} from '$lib/csv-import-pending';
import { fetchCsvImportStatus } from '$lib/csv-import-status-api';
import { totalSkippedFromCommit } from '$lib/csv-import-commit-metrics';
import { countCsvDataRows } from '$lib/csv-upload-row-count';
import CsvPendingImportsSheet from '$lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte';
/** Intenta extraer un objeto tipo scan desde string tipo repr de Python. */
@@ -113,6 +115,24 @@
? safeInt(skippedDuplicateMatch[1])
: 0;
const skippedMissingInvMatch =
s.match(/['"]skipped_missing_invoice['"]:\s*['"]?(\d+)['"]?/) ||
s.match(/['"]skipped_missing_invoice['"]:\s*([^,}]+)/);
const skipped_missing_invoice = skippedMissingInvMatch
? safeInt(skippedMissingInvMatch[1])
: 0;
const gapsMatch =
s.match(/['"]critical_reference_gaps['"]:\s*['"]?(\d+)['"]?/) ||
s.match(/['"]critical_reference_gaps['"]:\s*([^,}]+)/);
const critical_reference_gaps = gapsMatch ? safeInt(gapsMatch[1]) : 0;
const refReadyMatch = s.match(/['"]reference_state_ready['"]:\s*(True|False|true|false)/);
let reference_state_ready = true;
if (refReadyMatch) {
reference_state_ready = refReadyMatch[1].toLowerCase() === 'true';
}
// Detalle opcional: lista de dicts con {line, reason, solution?}
const skipped_details: Array<{ line: number; reason: string; solution?: string }> = [];
const detailRegex =
@@ -132,11 +152,19 @@
const message = messageMatch ? unescapePy(messageMatch[1]) : undefined;
// Si no hay contadores en el texto, no es un commit.
if (inserted === 0 && updated === 0 && skipped_invalid === 0 && skipped_missing_fk === 0 && skipped_duplicate === 0) {
if (
inserted === 0 &&
updated === 0 &&
skipped_invalid === 0 &&
skipped_missing_fk === 0 &&
skipped_duplicate === 0 &&
skipped_missing_invoice === 0 &&
critical_reference_gaps === 0
) {
return null;
}
// Mínimo requerido por el modal
// Mínimo requerido por el modal (contrato tipo pedimentos)
return {
status,
inserted,
@@ -144,6 +172,9 @@
skipped_invalid,
skipped_missing_fk,
skipped_duplicate,
skipped_missing_invoice,
critical_reference_gaps,
reference_state_ready,
skipped_details,
...(message ? { message } : {})
};
@@ -198,58 +229,85 @@
/** Job id del escaneo listo para confirmar (para quitar de pendientes al hacer commit). */
let scanPhaseJobId = $state<string | null>(null);
/**
* Total de filas de datos conocido para la barra (máx. entre conteo local, total del poll Celery y total_rows del escaneo).
* No se reinicia mientras el modal de resultados siga abierto, para poder usar el mismo total en el commit.
*/
let csvImportRowTotal = $state(0);
const csvProgressStepTitle = $derived.by(() => {
switch (csvProgressPhase) {
case 'upload':
return 'Paso 1 de 2 — Subiendo el archivo';
return 'Subiendo archivo CSV';
case 'scan':
return 'Paso 2 de 2 — Escaneando en el servidor';
return 'Validando registros en el servidor';
case 'commit':
return 'Finalizando importación';
return 'Grabando registros en base de datos';
default:
return '';
}
});
/** Denominador único: filas de la tarea (registros del CSV / total reportado por el worker). */
const csvImportProgressDenom = $derived.by(() => {
const d = Math.max(csvImportRowTotal || 0, scanProgressTotal || 0);
return d > 0 ? d : 1;
});
const csvProgressDetailLine = $derived.by(() => {
const denom = csvImportProgressDenom;
switch (csvProgressPhase) {
case 'upload':
return '';
if (csvImportRowTotal > 0) {
return `Archivo: ~${csvImportRowTotal} fila(s) de datos — subiendo (aún no se validan registros en servidor)…`;
}
return uploadLengthComputable
? 'Subiendo archivo…'
: 'Subiendo archivo (tamaño desconocido en el navegador)…';
case 'scan':
return scanProgressTotal > 0
? `Filas procesadas: ${scanProgressCurrent} / ${scanProgressTotal}`
: 'Preparando resultados…';
return `Registros procesados: ${Math.min(scanProgressCurrent, denom)} / ${denom}`;
case 'commit':
return 'Escribiendo registros en base de datos…';
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)`;
default:
return '';
}
});
const csvProgressBarIndeterminate = $derived(
csvProgressPhase === 'commit' ||
(csvProgressPhase === 'scan' && scanProgressTotal <= 0) ||
(csvProgressPhase === 'upload' && !uploadLengthComputable)
/** Porcentaje según filas procesadas / total de filas (sin tramos artificiales por etapa). */
const csvCombinedProgressPct = $derived.by(() => {
if (!isUploading || csvProgressPhase === 'idle') return 0;
if (csvProgressPhase === 'upload') {
return 0;
}
const denom = csvImportProgressDenom;
const numer = Math.min(Math.max(0, scanProgressCurrent), denom);
return Math.min(100, Math.round((100 * numer) / denom));
});
/**
* Commit: muchas tareas no publican PROGRESS por fila; si current sigue en 0, barra indeterminada.
* Cuando Celery envía current &gt; 0, la barra pasa a reflejar registros reales.
*/
const csvFooterProgressIndeterminate = $derived(
isUploading && !showResultModal && csvProgressPhase === 'commit' && scanProgressCurrent <= 0
);
const csvProgressBarValue = $derived(
csvProgressPhase === 'upload'
? uploadProgressPct
: csvProgressPhase === 'scan' && scanProgressTotal > 0
? Math.min(100, Math.round((scanProgressCurrent / scanProgressTotal) * 100))
: 0
const csvFooterPercentLabel = $derived(
csvFooterProgressIndeterminate
? null
: csvProgressPhase === 'upload'
? '0%'
: `${csvCombinedProgressPct}%`
);
const csvProgressPercentText = $derived(
csvProgressBarIndeterminate ? null : `${csvProgressBarValue}%`
);
const csvProgressAriaValueText = $derived(
csvProgressPercentText
? `${csvProgressStepTitle}, ${csvProgressPercentText}`
const csvFooterAriaValueText = $derived(
csvFooterProgressIndeterminate
? `${csvProgressStepTitle}. ${csvProgressDetailLine || 'En proceso…'}`
: csvProgressDetailLine
? `${csvProgressStepTitle}. ${csvProgressDetailLine}`
: csvProgressStepTitle
? `${csvProgressStepTitle}, ${csvFooterPercentLabel}. ${csvProgressDetailLine}`
: `${csvProgressStepTitle}, ${csvFooterPercentLabel}`
);
$effect(() => {
@@ -257,8 +315,12 @@
csvProgressPhase = 'idle';
uploadProgressPct = 0;
uploadLengthComputable = false;
scanProgressCurrent = 0;
scanProgressTotal = 0;
// Con modal abierto (escaneo listo o resultado de commit) conservar totales para la siguiente fase o la UI.
if (!showResultModal) {
scanProgressCurrent = 0;
scanProgressTotal = 0;
csvImportRowTotal = 0;
}
currentImportLabel = null;
csvResumeOverlayHint = false;
scanPhaseJobId = null;
@@ -523,6 +585,12 @@
uploadLengthComputable = false;
scanProgressCurrent = 0;
scanProgressTotal = 0;
csvImportRowTotal = 0;
try {
csvImportRowTotal = await countCsvDataRows(file);
} catch (e) {
console.warn('csv-upload: no se pudo contar filas del archivo', e);
}
csvResumeOverlayHint = false;
skipScanCompleteToastOnce = false;
scanPhaseJobId = null;
@@ -914,13 +982,20 @@
const p = (res.data as { progress?: unknown }).progress;
const t = (res.data as { total?: unknown }).total;
if (typeof p === 'number') scanProgressCurrent = p;
if (typeof t === 'number') scanProgressTotal = t;
if (typeof t === 'number') {
scanProgressTotal = t;
csvImportRowTotal = Math.max(csvImportRowTotal, t);
}
}
// Tratar como resultado de escaneo si viene status waiting_confirmation O si el payload tiene forma de scan (job_id + total_rows)
const looksLikeScanResult =
res.data?.status === 'waiting_confirmation' ||
(res.data?.job_id && typeof res.data?.total_rows === 'number');
if (looksLikeScanResult) {
const tr = (res.data as { total_rows?: unknown }).total_rows;
if (typeof tr === 'number' && tr > 0) {
csvImportRowTotal = Math.max(csvImportRowTotal, tr);
}
scanResults = res.data;
pushScanToPendingLocal(res.data as Record<string, unknown>);
showResultModal = true;
@@ -965,6 +1040,10 @@
}
}
if (parsedScan) {
const tr = parsedScan.total_rows;
if (typeof tr === 'number' && tr > 0) {
csvImportRowTotal = Math.max(csvImportRowTotal, tr);
}
scanResults = parsedScan;
pushScanToPendingLocal(parsedScan);
showResultModal = true;
@@ -1019,16 +1098,21 @@
commitResults = res.data;
showResultModal = true;
const inserted = res.data?.inserted || 0;
const skippedInvalid = res.data?.skipped_invalid || 0;
const skippedFk = res.data?.skipped_missing_fk || 0;
const skippedDup = res.data?.skipped_duplicate || 0;
const totalSkipped = skippedInvalid + skippedFk + skippedDup;
const updated = res.data?.updated || 0;
const totalSkipped = totalSkippedFromCommit(res.data as Record<string, unknown>);
const backendMessage = res.data?.message;
const totalOk = inserted + updated;
if (inserted === 0) {
toast.error(backendMessage || `No se insertaron registros. ${totalSkipped} fueron rechazados.`);
if (totalOk === 0) {
toast.error(
backendMessage ||
`No se insertaron ni actualizaron registros. ${totalSkipped} fueron rechazados.`
);
} else {
toast.warning(backendMessage || `Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`);
toast.warning(
backendMessage ||
`Se aplicaron ${totalOk} registros (${inserted} insertados, ${updated} actualizados). ${totalSkipped} rechazados.`
);
}
finalizeCommitAndClearPending();
isUploading = false;
@@ -1037,18 +1121,14 @@
showResultModal = true;
const inserted = res.data?.inserted || 0;
const updated = res.data?.updated || 0;
const skippedInvalid = res.data?.skipped_invalid || 0;
const skippedFk = res.data?.skipped_missing_fk || 0;
const skippedDup = res.data?.skipped_duplicate || 0;
const skippedDetails = res.data?.skipped_details || [];
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(', ')}`);
if (skippedInvalid > 0 || skippedFk > 0 || skippedDup > 0) {
const totalSkipped = skippedInvalid + skippedFk + skippedDup;
if (totalSkipped > 0) {
toast.warning(`${totalSkipped} registros fueron rechazados u omitidos`);
}
} else {
@@ -1059,14 +1139,14 @@
} else {
// Continue polling
console.log('Status not final, polling again in 2s...', res.data?.status);
setTimeout(pollStatus, 2000);
setTimeout(pollStatus, 800);
}
} catch (e) {
console.error('Poll exception', e);
// Retry on network error? Or fail?
// For now, let's keep retrying a few times or hard fail.
// Let's just log and retry.
setTimeout(pollStatus, 2000);
setTimeout(pollStatus, 800);
}
}
</script>
@@ -1093,7 +1173,11 @@
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">Catálogos Generales</h2>
</div>
<UploadLauncherGrid items={catalogosConfig} onUpload={handleUpload} />
<UploadLauncherGrid
items={catalogosConfig}
onUpload={handleUpload}
busy={isUploading}
/>
</Tabs.Content>
<Tabs.Content value="transportes" class="space-y-4">
@@ -1101,7 +1185,11 @@
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">Logística y Transporte</h2>
</div>
<UploadLauncherGrid items={transportesConfig} onUpload={handleUpload} />
<UploadLauncherGrid
items={transportesConfig}
onUpload={handleUpload}
busy={isUploading}
/>
</Tabs.Content>
<Tabs.Content value="importacion" class="space-y-4">
@@ -1109,67 +1197,93 @@
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">Operaciones de Importación</h2>
</div>
<UploadLauncherGrid items={importacionConfig} onUpload={handleUpload} />
<UploadLauncherGrid
items={importacionConfig}
onUpload={handleUpload}
busy={isUploading}
/>
</Tabs.Content>
<Tabs.Content value="exportacion" class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">Operaciones de Exportación</h2>
</div>
<UploadLauncherGrid items={exportacionConfig} onUpload={handleUpload} />
<UploadLauncherGrid
items={exportacionConfig}
onUpload={handleUpload}
busy={isUploading}
/>
</Tabs.Content>
</div>
</Tabs.Root>
<!-- Spacer at end of page: reserves space for fixed params bar so content is not cut off -->
<div class="h-[var(--csv-params-bar-height,6rem)] shrink-0" aria-hidden="true"></div>
<!-- Spacer: barra de parámetros + franja de progreso cuando aplica -->
<div
class="shrink-0"
style="height: calc(var(--csv-params-bar-height, 6rem) + {(isUploading && !showResultModal)
? '4.25rem'
: '0rem'});"
aria-hidden="true"
></div>
</div>
{#if isUploading && !showResultModal}
<div
class="fixed inset-0 z-50 flex flex-col items-center justify-center gap-3 bg-background/80 px-6 backdrop-blur-sm"
role="status"
aria-live="polite"
aria-busy="true"
>
{#if csvResumeOverlayHint}
<p class="max-w-md text-center text-xs text-muted-foreground">
Reanudando la importación guardada en esta pestaña…
</p>
{/if}
{#if currentImportLabel}
<p class="max-w-md text-center text-xs font-medium text-foreground">{currentImportLabel}</p>
{/if}
<div class="flex w-full max-w-md items-start justify-between gap-3">
<p class="flex-1 text-left text-sm font-medium text-foreground">{csvProgressStepTitle}</p>
{#if csvProgressPercentText}
<span class="shrink-0 tabular-nums text-sm font-semibold text-foreground" aria-hidden="true">
{csvProgressPercentText}
</span>
{/if}
</div>
{#if csvProgressDetailLine}
<p class="max-w-md text-center text-xs text-muted-foreground">{csvProgressDetailLine}</p>
{/if}
<div class="w-full max-w-md">
{#if csvProgressBarIndeterminate}
<div class="relative h-4 w-full overflow-hidden rounded-full bg-secondary">
<div class="csv-upload-indeterminate-bar absolute top-0 h-full w-2/5 rounded-full bg-primary" />
<!-- Pie fijo: progreso compacto + parámetros (sin overlay a pantalla completa) -->
<div
class="fixed right-0 bottom-0 left-0 z-40 ml-[calc(var(--sidebar-width))] flex flex-col border-t border-border bg-background/95 shadow-lg backdrop-blur group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] supports-[backdrop-filter]:bg-background/80"
>
{#if isUploading && !showResultModal}
<div class="border-b border-border/70">
<div
class="mx-auto w-full max-w-[1400px] space-y-1.5 px-4 py-2"
role="status"
aria-live="polite"
aria-busy="true"
>
{#if csvResumeOverlayHint}
<p class="text-xs text-muted-foreground">Reanudando la importación guardada en esta pestaña…</p>
{/if}
{#if currentImportLabel}
<p class="text-xs font-medium text-foreground">{currentImportLabel}</p>
{/if}
<div class="flex items-start justify-between gap-3">
<p class="min-w-0 flex-1 text-left text-xs font-medium leading-snug text-foreground md:text-sm">
{csvProgressStepTitle}
</p>
{#if csvFooterPercentLabel}
<span class="shrink-0 tabular-nums text-xs font-semibold text-foreground md:text-sm" aria-hidden="true">
{csvFooterPercentLabel}
</span>
{/if}
</div>
{:else}
<Progress
value={csvProgressBarValue}
max={100}
class="h-4"
aria-valuetext={csvProgressAriaValueText}
/>
{/if}
{#if csvProgressDetailLine}
<p class="text-[11px] leading-snug text-muted-foreground md:text-xs">{csvProgressDetailLine}</p>
{/if}
<div class="w-full">
{#if csvFooterProgressIndeterminate}
<div class="relative h-1.5 w-full overflow-hidden rounded-full bg-muted/70">
<div
class="csv-upload-indeterminate-bar absolute top-0 h-full w-2/5 rounded-full bg-primary"
/>
</div>
{:else}
<Progress
value={csvCombinedProgressPct}
max={100}
class="h-1.5 bg-muted/70"
aria-valuetext={csvFooterAriaValueText}
/>
{/if}
</div>
</div>
</div>
</div>
{/if}
<!-- Fixed params bar (always visible on this view) -->
<CsvParamsBar bind:globalSettings {activeTab} bind:tabSettingsValues={allSettings[activeTab]} />
{/if}
<CsvParamsBar
embedded
bind:globalSettings
{activeTab}
bind:tabSettingsValues={allSettings[activeTab]}
/>
</div>
</div>
{#if scanResults || commitResults}
@@ -1213,7 +1327,6 @@
isUploading = true;
csvProgressPhase = 'commit';
scanProgressCurrent = 0;
scanProgressTotal = 0;
const res = useCustomsBrokerImport
? await api.customsBrokerImports.commit(currentJobId)
: useClientProviderImport