feature/table-celery-tasks-resume
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
<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>
|
||||
@@ -480,32 +480,58 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer Actions -->
|
||||
<div class="px-6 py-4 bg-muted/20 border-t flex items-center justify-end gap-3">
|
||||
{#if isPending}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={onCancel}
|
||||
disabled={isUploading}
|
||||
class="text-muted-foreground hover:bg-muted/50"
|
||||
>
|
||||
Cancelar Operación
|
||||
</Button>
|
||||
<Button
|
||||
onclick={onConfirm}
|
||||
disabled={isUploading}
|
||||
class="bg-primary hover:bg-primary/90 text-primary-foreground min-w-[140px] shadow-sm"
|
||||
>
|
||||
{#if isUploading}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
Procesando...
|
||||
{:else}
|
||||
<UploadCloud class="mr-2 h-4 w-4" />
|
||||
Confirmar Carga
|
||||
{/if}
|
||||
</Button>
|
||||
{:else if isFinished}
|
||||
<Button variant="outline" onclick={onClose} class="min-w-[100px]">Cerrar</Button>
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
{#if isPending}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={onCancel}
|
||||
disabled={isUploading}
|
||||
class="text-muted-foreground hover:bg-muted/50"
|
||||
>
|
||||
Cancelar Operación
|
||||
</Button>
|
||||
<Button
|
||||
onclick={onConfirm}
|
||||
disabled={isUploading}
|
||||
class="min-w-[140px] bg-primary text-primary-foreground shadow-sm hover:bg-primary/90"
|
||||
>
|
||||
{#if isUploading}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
Procesando...
|
||||
{:else}
|
||||
<UploadCloud class="mr-2 h-4 w-4" />
|
||||
Confirmar Carga
|
||||
{/if}
|
||||
</Button>
|
||||
{:else if isFinished}
|
||||
<Button variant="outline" onclick={onClose} class="min-w-[100px]">Cerrar</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
@keyframes csv-commit-indeterminate {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
.csv-commit-indeterminate-bar {
|
||||
animation: csv-commit-indeterminate 1.2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
{...$$restProps}
|
||||
>
|
||||
<div
|
||||
class="h-full w-full flex-1 bg-primary transition-all"
|
||||
class="h-full w-full flex-1 bg-primary transition-transform duration-300 ease-out"
|
||||
style="transform: translateX(-{100 - (percentage || 0)}%)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user