Files
plantillas-proyectos/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte

216 lines
6.9 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import {
CSV_IMPORT_PENDING_CHANGED,
CSV_IMPORT_PENDING_KEY,
type CsvImportPendingEntry,
countCsvImportPendingForCompany,
listCsvImportPendingForCompany,
removeCsvImportPending,
updateCsvImportPendingSnapshot
} from '$lib/csv-import-pending';
import { fetchCsvImportStatus, isWaitingConfirmationPayload } from '$lib/csv-import-status-api';
import { Loader2, RefreshCw } from 'lucide-svelte';
type ValidatedRow = CsvImportPendingEntry & { checking?: boolean };
let {
open = $bindable(false),
companyId,
onResume
}: {
open?: boolean;
companyId: number | undefined;
onResume: (entry: CsvImportPendingEntry, scanPayload: Record<string, unknown>) => void;
} = $props();
let rows = $state<ValidatedRow[]>([]);
let refreshing = $state(false);
let badgeCount = $state(0);
function syncBadge() {
badgeCount = companyId !== undefined ? countCsvImportPendingForCompany(companyId) : 0;
}
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',
american_fractions: 'Fracciones arancelarias US',
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;
}
function isStaleJob(status: number, err: string | undefined): boolean {
if (status === 404) return true;
const m = (err || '').toLowerCase();
return /not found|no encontrado|404|expir|no disponible|invalid|inexistente/i.test(m);
}
async function validateAndLoad() {
if (companyId === undefined) {
rows = [];
return;
}
refreshing = true;
const raw = listCsvImportPendingForCompany(companyId);
const next: ValidatedRow[] = [];
for (const entry of raw) {
const res = await fetchCsvImportStatus(entry.jobId, entry.profile);
if (res.error && !res.data) {
if (isStaleJob(res.status, res.error)) {
removeCsvImportPending(entry.jobId);
continue;
}
next.push({ ...entry, checking: false });
continue;
}
if (res.data && isWaitingConfirmationPayload(res.data)) {
const d = res.data as Record<string, unknown>;
const tr = typeof d.total_rows === 'number' ? d.total_rows : undefined;
const vr = typeof d.valid_rows === 'number' ? d.valid_rows : undefined;
if (tr !== undefined || vr !== undefined) {
updateCsvImportPendingSnapshot(entry.jobId, { totalRows: tr, validRows: vr });
}
const updated = { ...entry, totalRows: tr ?? entry.totalRows, validRows: vr ?? entry.validRows };
next.push(updated);
} else {
removeCsvImportPending(entry.jobId);
}
}
rows = next;
refreshing = false;
syncBadge();
}
$effect(() => {
companyId;
syncBadge();
});
$effect(() => {
if (!browser || !open || companyId === undefined) return;
void validateAndLoad();
});
onMount(() => {
if (!browser) return;
const onPending = () => {
syncBadge();
if (open && companyId !== undefined) void validateAndLoad();
};
const onStorage = (e: StorageEvent) => {
if (e.key === CSV_IMPORT_PENDING_KEY) {
syncBadge();
if (open && companyId !== undefined) void validateAndLoad();
}
};
window.addEventListener(CSV_IMPORT_PENDING_CHANGED, onPending);
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener(CSV_IMPORT_PENDING_CHANGED, onPending);
window.removeEventListener('storage', onStorage);
};
});
function removeLocal(jobId: string) {
removeCsvImportPending(jobId);
rows = rows.filter((r) => r.jobId !== jobId);
syncBadge();
}
</script>
<Button variant="outline" size="sm" class="shrink-0" type="button" onclick={() => (open = true)}>
Pendientes
{#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}
</span>
{/if}
</Button>
<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.Description>
Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al
actualizar.
</Sheet.Description>
</Sheet.Header>
<div class="flex items-center justify-end gap-2 border-b px-4 py-2">
<Button variant="outline" size="sm" disabled={refreshing} onclick={() => void validateAndLoad()}>
{#if refreshing}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<RefreshCw class="mr-2 h-4 w-4" />
{/if}
Actualizar
</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>
{:else if rows.length === 0 && refreshing}
<p class="text-sm text-muted-foreground">Comprobando con el servidor…</p>
{:else}
<ul class="space-y-3">
{#each rows as row (row.jobId)}
<li class="rounded-lg border border-border bg-card p-3 text-sm shadow-sm">
<div class="font-medium text-foreground">
{row.label || profileLabel(row.profile)}
</div>
<div class="mt-0.5 text-xs text-muted-foreground">{profileLabel(row.profile)}</div>
{#if row.totalRows != null || row.validRows != null}
<div class="mt-1 text-xs text-muted-foreground">
{#if row.totalRows != null}
Total filas: {row.totalRows}
{/if}
{#if row.validRows != null}
<span class={row.totalRows != null ? ' · ' : ''}>Válidas: {row.validRows}</span>
{/if}
</div>
{/if}
<div class="mt-1 font-mono text-[10px] text-muted-foreground/80">{row.jobId}</div>
<div class="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
disabled={refreshing}
onclick={async () => {
const res = await fetchCsvImportStatus(row.jobId, row.profile);
if (res.data && isWaitingConfirmationPayload(res.data)) {
onResume(row, res.data as Record<string, unknown>);
open = false;
} else {
removeCsvImportPending(row.jobId);
rows = rows.filter((r) => r.jobId !== row.jobId);
syncBadge();
}
}}
>
Reanudar
</Button>
<Button size="sm" variant="ghost" onclick={() => removeLocal(row.jobId)}>Quitar</Button>
</div>
</li>
{/each}
</ul>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>