From 65eec3c4d31de5c758e099b02cbace8a0e4ebc6f Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 24 Mar 2026 16:17:13 -0600 Subject: [PATCH] feature/table-celery-tasks-resume --- frontend/src/lib/api.ts | 329 +++++++++-- .../csv-upload/CsvPendingImportsSheet.svelte | 215 +++++++ .../csv-upload/ProcessingResultModal.svelte | 76 ++- .../components/ui/progress/progress.svelte | 2 +- frontend/src/lib/csv-import-pending.ts | 151 +++++ frontend/src/lib/csv-import-session.ts | 131 +++++ frontend/src/lib/csv-import-status-api.ts | 49 ++ frontend/src/routes/dashboard/+layout.svelte | 49 ++ .../routes/dashboard/csv-upload/+page.svelte | 548 ++++++++++++++++-- 9 files changed, 1418 insertions(+), 132 deletions(-) create mode 100644 frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte create mode 100644 frontend/src/lib/csv-import-pending.ts create mode 100644 frontend/src/lib/csv-import-session.ts create mode 100644 frontend/src/lib/csv-import-status-api.ts diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a6df4b43..42b5290a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -243,6 +243,193 @@ async function fetchApi( } } +/** 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( + endpoint: string, + formData: FormData, + opts: CsvFormDataUploadOptions & { retryCount?: number } = {} +): Promise> { + const retryCount = opts.retryCount ?? 0; + + if (isRefreshing && retryCount === 0) { + return new Promise((resolve) => { + subscribeTokenRefresh(() => { + resolve(fetchApiFormDataPost(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(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 { const token = getToken(); const headers: Record = { @@ -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`), diff --git a/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte b/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte new file mode 100644 index 00000000..5ffb0f1a --- /dev/null +++ b/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte @@ -0,0 +1,215 @@ + + + + + + + + Importaciones pendientes de confirmar + + Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al + actualizar. + + +
+ +
+
+ {#if rows.length === 0 && !refreshing} +

No hay importaciones pendientes para esta empresa.

+ {:else if rows.length === 0 && refreshing} +

Comprobando con el servidor…

+ {:else} +
    + {#each rows as row (row.jobId)} +
  • +
    + {row.label || profileLabel(row.profile)} +
    +
    {profileLabel(row.profile)}
    + {#if row.totalRows != null || row.validRows != null} +
    + {#if row.totalRows != null} + Total filas: {row.totalRows} + {/if} + {#if row.validRows != null} + Válidas: {row.validRows} + {/if} +
    + {/if} +
    {row.jobId}
    +
    + + +
    +
  • + {/each} +
+ {/if} +
+
+
diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte index 154e6acb..05a98de3 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -480,32 +480,58 @@ -
- {#if isPending} - - - {:else if isFinished} - +
+ {#if isPending && isUploading} +
+

Importando registros…

+
+
+
+
{/if} +
+ {#if isPending} + + + {:else if isFinished} + + {/if} +
+ + diff --git a/frontend/src/lib/components/ui/progress/progress.svelte b/frontend/src/lib/components/ui/progress/progress.svelte index 1a45e279..9e6fac9c 100644 --- a/frontend/src/lib/components/ui/progress/progress.svelte +++ b/frontend/src/lib/components/ui/progress/progress.svelte @@ -21,7 +21,7 @@ {...$$restProps} >
diff --git a/frontend/src/lib/csv-import-pending.ts b/frontend/src/lib/csv-import-pending.ts new file mode 100644 index 00000000..fee48340 --- /dev/null +++ b/frontend/src/lib/csv-import-pending.ts @@ -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; + 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; + 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 & { 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 +): 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); +} diff --git a/frontend/src/lib/csv-import-session.ts b/frontend/src/lib/csv-import-session.ts new file mode 100644 index 00000000..b895ca85 --- /dev/null +++ b/frontend/src/lib/csv-import-session.ts @@ -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; + 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 { + // + } +} diff --git a/frontend/src/lib/csv-import-status-api.ts b/frontend/src/lib/csv-import-status-api.ts new file mode 100644 index 00000000..6dd46f68 --- /dev/null +++ b/frontend/src/lib/csv-import-status-api.ts @@ -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 { + 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; + if (d.status === 'waiting_confirmation') return true; + return typeof d.job_id === 'string' && typeof d.total_rows === 'number'; +} diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 3d9820a7..12e28e3f 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -10,6 +10,8 @@ import ExchangeRateGuard from '$lib/components/dashboard/exchange_rate/exchange-rate-guard.svelte'; import SessionTimeoutWarning from '$lib/components/session-timeout-warning.svelte'; import { page } from '$app/state'; + import { browser } from '$app/environment'; + import { CSV_IMPORT_SESSION_CHANGED, readCsvImportSession } from '$lib/csv-import-session'; import { createSessionManager, destroySessionManager, @@ -21,6 +23,35 @@ let { data, children }: { data: LayoutData; children: any } = $props(); + let csvImportBanner = $state(false); + let csvImportBannerLabel = $state(null); + + function updateCsvImportBanner() { + if (!browser) { + csvImportBanner = false; + csvImportBannerLabel = null; + return; + } + const path = page.url.pathname; + const cid = companyStore.activeCompany?.id; + if (path.includes('/dashboard/csv-upload')) { + csvImportBanner = false; + csvImportBannerLabel = null; + return; + } + const session = readCsvImportSession(cid); + csvImportBanner = session != null; + const raw = session?.label; + csvImportBannerLabel = typeof raw === 'string' && raw.trim() ? raw.trim() : null; + } + + $effect(() => { + if (!browser) return; + page.url.pathname; + companyStore.activeCompany?.id; + updateCsvImportBanner(); + }); + // Hacer disponible el usuario en el contexto para los componentes hijos setContext('user', data.user); setContext('userTenants', data.userTenants ?? []); @@ -71,9 +102,12 @@ // ── Escuchar cambios de compañía y recargar datos ───────────────────── const handleCompanyChange = () => invalidateAll(); window.addEventListener('companyChanged', handleCompanyChange); + window.addEventListener(CSV_IMPORT_SESSION_CHANGED, updateCsvImportBanner); + updateCsvImportBanner(); return () => { window.removeEventListener('companyChanged', handleCompanyChange); + window.removeEventListener(CSV_IMPORT_SESSION_CHANGED, updateCsvImportBanner); window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired); }; }); @@ -108,6 +142,21 @@
diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte index 80a8958d..26cdc4bc 100644 --- a/frontend/src/routes/dashboard/csv-upload/+page.svelte +++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte @@ -1,4 +1,6 @@