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" />