feature/table-celery-tasks-resume

This commit is contained in:
2026-03-24 16:17:13 -06:00
parent 481b295ab0
commit 65eec3c4d3
9 changed files with 1418 additions and 132 deletions

View File

@@ -243,6 +243,193 @@ async function fetchApi<T = any>(
}
}
/** Opciones para subidas CSV (FormData) con progreso de red. */
export type CsvFormDataUploadOptions = {
onUploadProgress?: (e: { loaded: number; total: number }) => void;
};
/**
* POST multipart/form-data con XMLHttpRequest para exponer progreso de subida.
* Misma semántica de auth/401/403/422 que fetchApi.
*/
async function fetchApiFormDataPost<T = any>(
endpoint: string,
formData: FormData,
opts: CsvFormDataUploadOptions & { retryCount?: number } = {}
): Promise<ApiResponse<T>> {
const retryCount = opts.retryCount ?? 0;
if (isRefreshing && retryCount === 0) {
return new Promise((resolve) => {
subscribeTokenRefresh(() => {
resolve(fetchApiFormDataPost<T>(endpoint, formData, { ...opts, retryCount: 1 }));
});
});
}
return new Promise((resolve) => {
const token = getToken();
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE_URL}${endpoint}`);
xhr.withCredentials = true;
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
xhr.upload.onprogress = (ev) => {
if (!opts.onUploadProgress) return;
if (ev.lengthComputable) {
opts.onUploadProgress({ loaded: ev.loaded, total: ev.total });
} else {
opts.onUploadProgress({ loaded: ev.loaded, total: 0 });
}
};
xhr.onload = () => {
void (async () => {
const status = xhr.status;
let data: any = null;
if (xhr.responseText) {
try {
data = JSON.parse(xhr.responseText) as any;
} catch {
data = null;
}
}
if ((status === 401 || status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) {
if (status === 403) {
if (browser) {
toast.error('No tienes permisos para realizar esta acción', {
duration: 4000,
description: 'Contacta a tu administrador si crees que esto es un error'
});
}
resolve({
error: data?.detail || 'No tienes permisos para realizar esta acción',
status: 403
});
return;
}
isRefreshing = true;
try {
const newToken = await refreshToken();
if (newToken) {
onTokenRefreshed(newToken);
isRefreshing = false;
resolve(await fetchApiFormDataPost<T>(endpoint, formData, { ...opts, retryCount: 1 }));
} else {
console.error('❌ [API] No se pudo refrescar el token');
isRefreshing = false;
resolve({
error: 'Sesión expirada. Por favor, inicia sesión nuevamente.',
status: 401
});
}
} catch (refreshError) {
console.error('❌ [API] Error al refrescar:', refreshError);
isRefreshing = false;
resolve({
error: 'Error al refrescar la sesión',
status: 401
});
}
return;
}
if (status === 204) {
resolve({
data: null as T,
status
});
return;
}
if (status === 0) {
resolve({
error: 'Error de conexión con el servidor',
status: 0
});
return;
}
if (status < 200 || status >= 300) {
if (status === 422 && data) {
if (data.errors && Array.isArray(data.errors)) {
resolve({
error: data.message || 'Error de validación',
validationErrors: data.errors,
status: 422
});
return;
}
if (data.detail) {
let errorMessage = 'Error de validación: ';
if (Array.isArray(data.detail)) {
const errors = data.detail
.map((err: any) => {
const field = err.loc ? err.loc.join('.') : 'campo desconocido';
return `${field}: ${err.msg}`;
})
.join(', ');
errorMessage += errors;
} else if (typeof data.detail === 'string') {
errorMessage = data.detail;
} else {
errorMessage += JSON.stringify(data.detail);
}
resolve({
error: errorMessage,
status: 422
});
return;
}
}
resolve({
error:
data?.message ||
(typeof data?.detail === 'string' ? data.detail : JSON.stringify(data?.detail)) ||
'Error en la petición',
status
});
return;
}
if (data === null && xhr.responseText) {
resolve({
error: 'Respuesta inválida del servidor',
status
});
return;
}
resolve({
data,
status
});
})();
};
xhr.onerror = () => {
resolve({
error: 'Error de conexión con el servidor',
status: 0
});
};
try {
xhr.send(formData);
} catch (error) {
console.error(`❌ [API] Error al enviar ${endpoint}:`, error);
resolve({
error: 'Error de conexión con el servidor',
status: 0
});
}
});
}
async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise<Blob> {
const token = getToken();
const headers: Record<string, string> = {
@@ -357,7 +544,8 @@ export const api = {
footerConfig: any,
companyId: number,
operationType: string,
templateId?: string
templateId?: string,
uploadOptions?: CsvFormDataUploadOptions
) => {
const formData = new FormData();
formData.append('file', file);
@@ -373,10 +561,11 @@ export const api = {
operation_type: operationType || 'imp'
}).toString();
return fetchApi(`/v1/a76/imports/upload/${modelTarget}?${queryParams}`, {
method: 'POST',
body: formData
});
return fetchApiFormDataPost(
`/v1/a76/imports/upload/${modelTarget}?${queryParams}`,
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/imports/${jobId}/status`),
commit: (jobId: string, modelTarget: string) =>
@@ -393,7 +582,8 @@ export const api = {
modelTarget: string,
footerConfig: any,
companyId: number,
templateId?: string
templateId?: string,
uploadOptions?: CsvFormDataUploadOptions
) => {
const formData = new FormData();
formData.append('file', file);
@@ -409,10 +599,11 @@ export const api = {
operation_type: 'exp'
}).toString();
return fetchApi(`/v1/a76/imports/exportacion/upload/${modelTarget}?${queryParams}`, {
method: 'POST',
body: formData
});
return fetchApiFormDataPost(
`/v1/a76/imports/exportacion/upload/${modelTarget}?${queryParams}`,
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/imports/exportacion/${jobId}/status`),
commit: (jobId: string, modelTarget: string) =>
@@ -423,12 +614,13 @@ export const api = {
// CSV import for Agentes Aduanales (flujo propio en customs_brokers/imports)
customsBrokerImports: {
upload: (file: File, companyId: number) => {
upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/customs-brokers/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/customs-brokers/imports/${jobId}/status`),
@@ -440,12 +632,13 @@ export const api = {
// CSV import for Clientes y Proveedores (flujo propio en clients_and_providers/imports)
clientProviderImports: {
upload: (file: File, companyId: number) => {
upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/clients-providers/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/clients-providers/imports/${jobId}/status`),
@@ -460,7 +653,8 @@ export const api = {
upload: (
file: File,
companyId: number,
params?: { reemplazar_sin_preguntar?: boolean; date_format?: string }
params?: { reemplazar_sin_preguntar?: boolean; date_format?: string },
uploadOptions?: CsvFormDataUploadOptions
) => {
const formData = new FormData();
formData.append('file', file);
@@ -469,9 +663,10 @@ export const api = {
search.set('reemplazar_sin_preguntar', String(!!params.reemplazar_sin_preguntar));
if (params?.date_format != null && params.date_format !== '')
search.set('date_format', params.date_format);
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/exchange-rate/imports/upload?${search.toString()}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/exchange-rate/imports/${jobId}/status`),
@@ -483,12 +678,13 @@ export const api = {
// CSV import for Fracción Americana (us_tariff_fractions/imports)
americanFractionImports: {
upload: (file: File, companyId: number) => {
upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/us-tariff-fractions/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/us-tariff-fractions/imports/${jobId}/status`),
@@ -503,16 +699,18 @@ export const api = {
upload: (
file: File,
companyId: number,
params?: { actualizar?: boolean; dateFormat?: string }
params?: { actualizar?: boolean; dateFormat?: string },
uploadOptions?: CsvFormDataUploadOptions
) => {
const formData = new FormData();
formData.append('file', file);
const search = new URLSearchParams({ company_id: String(companyId) });
if (params?.actualizar !== undefined) search.set('actualizar', String(!!params.actualizar));
if (params?.dateFormat != null) search.set('dateFormat', params.dateFormat);
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/pedimentos/imports/upload?${search.toString()}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/pedimentos/imports/${jobId}/status`),
@@ -527,16 +725,18 @@ export const api = {
upload: (
file: File,
companyId: number,
params?: { actualizar?: boolean; siempre_toda?: boolean }
params?: { actualizar?: boolean; siempre_toda?: boolean },
uploadOptions?: CsvFormDataUploadOptions
) => {
const formData = new FormData();
formData.append('file', file);
const search = new URLSearchParams({ company_id: String(companyId) });
if (params?.actualizar !== undefined) search.set('actualizar', String(!!params.actualizar));
if (params?.siempre_toda !== undefined) search.set('siempre_toda', String(!!params.siempre_toda));
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/classes/imports/upload?${search.toString()}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/classes/imports/${jobId}/status`),
@@ -548,14 +748,21 @@ export const api = {
// CSV import for Vehículos / Transportes (transportation/vehicles/imports)
vehicleImports: {
upload: (file: File, companyId: number, options?: { actualizar?: boolean }) => {
upload: (
file: File,
companyId: number,
options?: { actualizar?: boolean; onUploadProgress?: CsvFormDataUploadOptions['onUploadProgress'] }
) => {
const formData = new FormData();
formData.append('file', file);
const params = new URLSearchParams({ company_id: String(companyId) });
if (options?.actualizar !== undefined) params.set('actualizar', String(options.actualizar));
return fetchApi(
const uploadOpts =
options?.onUploadProgress != null ? { onUploadProgress: options.onUploadProgress } : undefined;
return fetchApiFormDataPost(
`/v1/a76/transportation/vehicles/imports/upload?${params.toString()}`,
{ method: 'POST', body: formData }
formData,
uploadOpts
);
},
status: (jobId: string) => api.get(`/v1/a76/transportation/vehicles/imports/${jobId}/status`),
@@ -567,12 +774,13 @@ export const api = {
// CSV import for Conductores (drivers/imports)
driverImports: {
upload: (file: File, companyId: number) => {
upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/drivers/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/drivers/imports/${jobId}/status`),
@@ -583,13 +791,20 @@ export const api = {
// CSV import for Trailers y Cajas (transportation/trailers/imports)
trailerImports: {
upload: (file: File, companyId: number, params?: { actualizar?: boolean }) => {
upload: (
file: File,
companyId: number,
params?: { actualizar?: boolean; onUploadProgress?: CsvFormDataUploadOptions['onUploadProgress'] }
) => {
const formData = new FormData();
formData.append('file', file);
const actualizar = params?.actualizar ?? false;
return fetchApi(
const uploadOpts =
params?.onUploadProgress != null ? { onUploadProgress: params.onUploadProgress } : undefined;
return fetchApiFormDataPost(
`/v1/a76/transportation/trailers/imports/upload?company_id=${companyId}&actualizar=${actualizar}`,
{ method: 'POST', body: formData }
formData,
uploadOpts
);
},
status: (jobId: string) => api.get(`/v1/a76/transportation/trailers/imports/${jobId}/status`),
@@ -601,13 +816,20 @@ export const api = {
// CSV import for Transportistas (transporters/imports)
transporterImports: {
upload: (file: File, companyId: number, params?: { actualizar?: boolean }) => {
upload: (
file: File,
companyId: number,
params?: { actualizar?: boolean; onUploadProgress?: CsvFormDataUploadOptions['onUploadProgress'] }
) => {
const formData = new FormData();
formData.append('file', file);
const actualizar = params?.actualizar ?? false;
return fetchApi(
const uploadOpts =
params?.onUploadProgress != null ? { onUploadProgress: params.onUploadProgress } : undefined;
return fetchApiFormDataPost(
`/v1/a76/transporters/imports/upload?company_id=${companyId}&actualizar=${actualizar}`,
{ method: 'POST', body: formData }
formData,
uploadOpts
);
},
status: (jobId: string) => api.get(`/v1/a76/transporters/imports/${jobId}/status`),
@@ -618,15 +840,27 @@ export const api = {
// CSV import for Números de parte (parts/imports)
partNumberImports: {
upload: (file: File, companyId: number, options?: { actualizar?: boolean; reemplazar_sin_preguntar?: boolean }) => {
upload: (
file: File,
companyId: number,
options?: {
actualizar?: boolean;
reemplazar_sin_preguntar?: boolean;
onUploadProgress?: CsvFormDataUploadOptions['onUploadProgress'];
}
) => {
const formData = new FormData();
formData.append('file', file);
const params = new URLSearchParams({ company_id: String(companyId) });
if (options?.actualizar !== undefined) params.set('actualizar', String(options.actualizar));
if (options?.reemplazar_sin_preguntar !== undefined) params.set('reemplazar_sin_preguntar', String(options.reemplazar_sin_preguntar));
return fetchApi(
if (options?.reemplazar_sin_preguntar !== undefined)
params.set('reemplazar_sin_preguntar', String(options.reemplazar_sin_preguntar));
const uploadOpts =
options?.onUploadProgress != null ? { onUploadProgress: options.onUploadProgress } : undefined;
return fetchApiFormDataPost(
`/v1/a76/parts/imports/upload?${params.toString()}`,
{ method: 'POST', body: formData }
formData,
uploadOpts
);
},
status: (jobId: string) => api.get(`/v1/a76/parts/imports/${jobId}/status`),
@@ -637,12 +871,13 @@ export const api = {
// CSV import for BOMs (boms/imports)
bomImports: {
upload: (file: File, companyId: number) => {
upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => {
const formData = new FormData();
formData.append('file', file);
return fetchApi(
return fetchApiFormDataPost(
`/v1/a76/boms/imports/upload?company_id=${companyId}`,
{ method: 'POST', body: formData }
formData,
uploadOptions
);
},
status: (jobId: string) => api.get(`/v1/a76/boms/imports/${jobId}/status`),

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,151 @@
import { browser } from '$app/environment';
import type { CsvImportProfile, CsvImportTab } from '$lib/csv-import-session';
const VALID_PROFILES: CsvImportProfile[] = [
'customs_brokers',
'clients_providers',
'exchange_rates',
'american_fractions',
'pedimentos',
'material_classes',
'vehicles',
'drivers',
'trailers',
'transporters',
'part_numbers',
'boms',
'exportacion',
'imports'
];
const VALID_TABS: CsvImportTab[] = ['catalogos', 'transportes', 'importacion', 'exportacion'];
export const CSV_IMPORT_PENDING_KEY = 'anexo76_csv_pending_v1';
export const CSV_IMPORT_PENDING_CHANGED = 'csvImportPendingChanged';
const MAX_ITEMS = 20;
export interface CsvImportPendingEntry {
companyId: number;
jobId: string;
profile: CsvImportProfile;
activeModelTarget: string | null;
activeTab: CsvImportTab;
label?: string | null;
savedAt: string;
totalRows?: number;
validRows?: number;
}
interface PendingStoreV1 {
v: 1;
items: CsvImportPendingEntry[];
}
function notifyPendingChanged() {
if (browser) {
window.dispatchEvent(new CustomEvent(CSV_IMPORT_PENDING_CHANGED));
}
}
function parseStore(raw: string | null): PendingStoreV1 {
if (!raw) return { v: 1, items: [] };
try {
const data = JSON.parse(raw) as Partial<PendingStoreV1>;
if (data.v !== 1 || !Array.isArray(data.items)) return { v: 1, items: [] };
return { v: 1, items: data.items.filter(isValidEntry) };
} catch {
return { v: 1, items: [] };
}
}
function isValidEntry(x: unknown): x is CsvImportPendingEntry {
if (!x || typeof x !== 'object') return false;
const o = x as Record<string, unknown>;
return (
typeof o.companyId === 'number' &&
typeof o.jobId === 'string' &&
typeof o.profile === 'string' &&
VALID_PROFILES.includes(o.profile as CsvImportProfile) &&
typeof o.savedAt === 'string' &&
(o.activeModelTarget === null || typeof o.activeModelTarget === 'string') &&
typeof o.activeTab === 'string' &&
VALID_TABS.includes(o.activeTab as CsvImportTab)
);
}
function writeStore(store: PendingStoreV1, notify = true) {
if (!browser) return;
try {
localStorage.setItem(CSV_IMPORT_PENDING_KEY, JSON.stringify(store));
if (notify) notifyPendingChanged();
} catch {
//
}
}
export function readCsvImportPendingStore(): PendingStoreV1 {
if (!browser) return { v: 1, items: [] };
return parseStore(localStorage.getItem(CSV_IMPORT_PENDING_KEY));
}
export function listCsvImportPendingForCompany(companyId: number | undefined): CsvImportPendingEntry[] {
if (companyId === undefined) return [];
const { items } = readCsvImportPendingStore();
return items
.filter((i) => i.companyId === companyId)
.sort((a, b) => (a.savedAt < b.savedAt ? 1 : -1));
}
export function countCsvImportPendingForCompany(companyId: number | undefined): number {
return listCsvImportPendingForCompany(companyId).length;
}
/** Inserta o actualiza por jobId; mantiene como máximo MAX_ITEMS (más recientes primero). */
export function upsertCsvImportPending(partial: Omit<CsvImportPendingEntry, 'savedAt'> & { savedAt?: string }): void {
if (!browser) return;
const store = readCsvImportPendingStore();
const now = partial.savedAt ?? new Date().toISOString();
const next: CsvImportPendingEntry = {
companyId: partial.companyId,
jobId: partial.jobId,
profile: partial.profile,
activeModelTarget: partial.activeModelTarget,
activeTab: partial.activeTab,
savedAt: now,
...(partial.label !== undefined && partial.label !== null && String(partial.label).trim()
? { label: String(partial.label).trim() }
: {}),
...(typeof partial.totalRows === 'number' ? { totalRows: partial.totalRows } : {}),
...(typeof partial.validRows === 'number' ? { validRows: partial.validRows } : {})
};
const without = store.items.filter((i) => i.jobId !== next.jobId);
const merged = [next, ...without].sort((a, b) => (a.savedAt < b.savedAt ? 1 : -1)).slice(0, MAX_ITEMS);
writeStore({ v: 1, items: merged });
}
export function removeCsvImportPending(jobId: string): void {
if (!browser) return;
const store = readCsvImportPendingStore();
const filtered = store.items.filter((i) => i.jobId !== jobId);
if (filtered.length === store.items.length) return;
writeStore({ v: 1, items: filtered });
}
export function updateCsvImportPendingSnapshot(
jobId: string,
patch: Pick<CsvImportPendingEntry, 'totalRows' | 'validRows'>
): void {
if (!browser) return;
const store = readCsvImportPendingStore();
const idx = store.items.findIndex((i) => i.jobId === jobId);
if (idx < 0) return;
const cur = store.items[idx];
store.items[idx] = {
...cur,
...(typeof patch.totalRows === 'number' ? { totalRows: patch.totalRows } : {}),
...(typeof patch.validRows === 'number' ? { validRows: patch.validRows } : {})
};
writeStore({ v: 1, items: [...store.items] }, false);
}

View File

@@ -0,0 +1,131 @@
import { browser } from '$app/environment';
export const CSV_IMPORT_SESSION_KEY = 'anexo76_csv_import_v1';
export const CSV_IMPORT_SESSION_CHANGED = 'csvImportSessionChanged';
/** Debe coincidir con la cadena de `pollStatus` en csv-upload/+page.svelte */
export type CsvImportProfile =
| 'customs_brokers'
| 'clients_providers'
| 'exchange_rates'
| 'american_fractions'
| 'pedimentos'
| 'material_classes'
| 'vehicles'
| 'drivers'
| 'trailers'
| 'transporters'
| 'part_numbers'
| 'boms'
| 'exportacion'
| 'imports';
export type CsvImportTab = 'catalogos' | 'transportes' | 'importacion' | 'exportacion';
export interface CsvImportSessionV1 {
v: 1;
companyId: number;
jobId: string;
profile: CsvImportProfile;
activeModelTarget: string | null;
activeTab: CsvImportTab;
/** Título del ítem de carga (p. ej. nombre del catálogo) para banner / contexto */
label?: string | null;
}
function notifySessionChanged() {
if (browser) {
window.dispatchEvent(new CustomEvent(CSV_IMPORT_SESSION_CHANGED));
}
}
export function readCsvImportSession(activeCompanyId: number | undefined): CsvImportSessionV1 | null {
if (!browser || activeCompanyId === undefined) return null;
try {
const raw = sessionStorage.getItem(CSV_IMPORT_SESSION_KEY);
if (!raw) return null;
const data = JSON.parse(raw) as Partial<CsvImportSessionV1>;
if (data.v !== 1 || typeof data.jobId !== 'string' || typeof data.companyId !== 'number') {
return null;
}
if (data.companyId !== activeCompanyId) return null;
if (!isCsvImportProfile(data.profile)) return null;
if (!isCsvImportTab(data.activeTab)) return null;
const label =
data.label === null || data.label === undefined
? undefined
: typeof data.label === 'string'
? data.label
: undefined;
return {
v: 1,
companyId: data.companyId,
jobId: data.jobId,
profile: data.profile,
activeModelTarget:
data.activeModelTarget === null || data.activeModelTarget === undefined
? null
: typeof data.activeModelTarget === 'string'
? data.activeModelTarget
: null,
activeTab: data.activeTab,
...(label !== undefined ? { label } : {})
};
} catch {
return null;
}
}
export function hasCsvImportSessionForCompany(activeCompanyId: number | undefined): boolean {
return readCsvImportSession(activeCompanyId) !== null;
}
function isCsvImportProfile(p: unknown): p is CsvImportProfile {
return (
typeof p === 'string' &&
[
'customs_brokers',
'clients_providers',
'exchange_rates',
'american_fractions',
'pedimentos',
'material_classes',
'vehicles',
'drivers',
'trailers',
'transporters',
'part_numbers',
'boms',
'exportacion',
'imports'
].includes(p)
);
}
function isCsvImportTab(t: unknown): t is CsvImportTab {
return (
typeof t === 'string' &&
['catalogos', 'transportes', 'importacion', 'exportacion'].includes(t)
);
}
export function saveCsvImportSession(session: CsvImportSessionV1): void {
if (!browser) return;
try {
sessionStorage.setItem(CSV_IMPORT_SESSION_KEY, JSON.stringify(session));
notifySessionChanged();
} catch {
// quota / private mode
}
}
export function clearCsvImportSession(): void {
if (!browser) return;
try {
sessionStorage.removeItem(CSV_IMPORT_SESSION_KEY);
notifySessionChanged();
} catch {
//
}
}

View File

@@ -0,0 +1,49 @@
import { api, type ApiResponse } from '$lib/api';
import type { CsvImportProfile } from '$lib/csv-import-session';
/**
* Consulta el estado de un job CSV según el perfil de importación (misma lógica que pollStatus en csv-upload).
*/
export function fetchCsvImportStatus(
jobId: string,
profile: CsvImportProfile
): Promise<ApiResponse> {
switch (profile) {
case 'customs_brokers':
return api.customsBrokerImports.status(jobId);
case 'clients_providers':
return api.clientProviderImports.status(jobId);
case 'exchange_rates':
return api.exchangeRateImports.status(jobId);
case 'american_fractions':
return api.americanFractionImports.status(jobId);
case 'pedimentos':
return api.pedimentosImports.status(jobId);
case 'material_classes':
return api.materialClassImports.status(jobId);
case 'vehicles':
return api.vehicleImports.status(jobId);
case 'drivers':
return api.driverImports.status(jobId);
case 'trailers':
return api.trailerImports.status(jobId);
case 'transporters':
return api.transporterImports.status(jobId);
case 'part_numbers':
return api.partNumberImports.status(jobId);
case 'boms':
return api.bomImports.status(jobId);
case 'exportacion':
return api.exportacionImports.status(jobId);
case 'imports':
default:
return api.imports.status(jobId);
}
}
export function isWaitingConfirmationPayload(data: unknown): boolean {
if (!data || typeof data !== 'object') return false;
const d = data as Record<string, unknown>;
if (d.status === 'waiting_confirmation') return true;
return typeof d.job_id === 'string' && typeof d.total_rows === 'number';
}