checkpoint
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user