Merge branch 'development' into feature/data-entry
This commit is contained in:
@@ -40,94 +40,49 @@ function onTokenRefreshed(token: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Intenta refrescar el token usando el refresh token
|
||||
* Refresca el token silenciosamente usando el endpoint server-side.
|
||||
*
|
||||
* El servidor lee el refresh_token desde la cookie HttpOnly,
|
||||
* llama a Keycloak, actualiza las cookies y devuelve el nuevo access_token.
|
||||
* El refresh_token NUNCA es leído por este código JavaScript.
|
||||
*/
|
||||
async function refreshToken(): Promise<string | null> {
|
||||
if (!browser) return null;
|
||||
|
||||
let refreshTokenValue = localStorage.getItem('refresh_token');
|
||||
|
||||
// Si no está en localStorage, intentar obtenerlo de las cookies
|
||||
if (!refreshTokenValue) {
|
||||
const getCookie = (name: string): string | null => {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
|
||||
return null;
|
||||
};
|
||||
|
||||
refreshTokenValue = getCookie('refresh_token');
|
||||
if (refreshTokenValue) {
|
||||
localStorage.setItem('refresh_token', refreshTokenValue);
|
||||
}
|
||||
}
|
||||
|
||||
if (!refreshTokenValue) {
|
||||
console.error('❌ [API] No hay refresh token disponible');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/v1/auth/refresh`, {
|
||||
const response = await fetch('/api-sveltekit/auth/silent-refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshTokenValue }),
|
||||
credentials: 'include'
|
||||
credentials: 'include', // Envía cookies HttpOnly automáticamente
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ [API] Refresh token expirado o inválido, status:', response.status);
|
||||
// Si el refresh token también está expirado, limpiar todo
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
// Limpiar cookies también
|
||||
console.error('❌ [API] Silent refresh falló, status:', response.status);
|
||||
// Limpiar la cookie del access_token (no HttpOnly) para forzar re-login
|
||||
document.cookie = 'access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC';
|
||||
document.cookie = 'refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC';
|
||||
// Redirigir al login después de un pequeño delay para que el usuario vea el mensaje
|
||||
setTimeout(() => {
|
||||
if (browser) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}, 2000);
|
||||
setTimeout(() => { window.location.href = '/login'; }, 1500);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json() as { access_token?: string };
|
||||
|
||||
// Guardar los nuevos tokens
|
||||
if (data.access_token) {
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
// Actualizar cookie no-HttpOnly del access_token
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
||||
document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secure}`;
|
||||
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
}
|
||||
|
||||
// Actualizar también las cookies
|
||||
const isSecure = window.location.protocol === 'https:';
|
||||
const secureFlag = isSecure ? '; Secure' : '';
|
||||
|
||||
document.cookie = `access_token=${data.access_token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax${secureFlag}`;
|
||||
if (data.refresh_token) {
|
||||
document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax${secureFlag}`;
|
||||
}
|
||||
|
||||
// Actualizar el authStore si está disponible
|
||||
// Actualizar authStore en memoria
|
||||
try {
|
||||
const { authStore } = await import('./auth');
|
||||
authStore.setToken(data.access_token);
|
||||
} catch (e) {
|
||||
// Si no se puede importar authStore, no es crítico
|
||||
console.warn('⚠️ [API] No se pudo actualizar authStore:', e);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ [API] Error refreshing token:', error);
|
||||
console.error('❌ [API] Error en silent refresh:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -315,6 +270,35 @@ export const api = {
|
||||
|
||||
delete: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, { method: 'DELETE', ...options }),
|
||||
|
||||
/**
|
||||
* Download CSV template by template_id (generated from code, no static file).
|
||||
* Returns blob and suggested filename for the browser download.
|
||||
*/
|
||||
async getCsvTemplateDownload(
|
||||
templateId: string
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const response = await fetch(`${API_BASE_URL}/v1/a76/csv-templates/${templateId}`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const msg = response.status === 404 ? 'Plantilla no encontrada' : `Error ${response.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
let filename = `plantilla_${templateId}.csv`;
|
||||
const disposition = response.headers.get('Content-Disposition');
|
||||
if (disposition) {
|
||||
const match = /filename="?([^";\n]+)"?/.exec(disposition);
|
||||
if (match) filename = match[1].trim();
|
||||
}
|
||||
return { blob, filename };
|
||||
},
|
||||
|
||||
// Endpoints específicos
|
||||
auth: {
|
||||
login: (credentials: { username: string; password: string; tenant_slug: string }) =>
|
||||
@@ -341,6 +325,215 @@ export const api = {
|
||||
validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}/`)
|
||||
},
|
||||
|
||||
imports: {
|
||||
upload: (
|
||||
file: File,
|
||||
modelTarget: string,
|
||||
footerConfig: any,
|
||||
companyId: number,
|
||||
operationType: string,
|
||||
templateId?: string
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (footerConfig) {
|
||||
formData.append('footer_config', JSON.stringify(footerConfig));
|
||||
}
|
||||
if (templateId) {
|
||||
formData.append('template_id', templateId);
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
company_id: String(companyId),
|
||||
operation_type: operationType || 'imp'
|
||||
}).toString();
|
||||
|
||||
return fetchApi(`/v1/a76/imports/upload/${modelTarget}?${queryParams}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/imports/${jobId}/status`),
|
||||
commit: (jobId: string, modelTarget: string) =>
|
||||
api.post(`/v1/a76/imports/${jobId}/commit`, { model_target: modelTarget })
|
||||
},
|
||||
|
||||
// CSV import for Agentes Aduanales (flujo propio en customs_brokers/imports)
|
||||
customsBrokerImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/customs-brokers/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/customs-brokers/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/customs-brokers/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Clientes y Proveedores (flujo propio en clients_and_providers/imports)
|
||||
clientProviderImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/clients-providers/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/clients-providers/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/clients-providers/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Tipos de Cambio (flujo en general_catalogs/exchange_rate/imports)
|
||||
exchangeRateImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/exchange-rate/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/exchange-rate/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/exchange-rate/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Fracción Americana (us_tariff_fractions/imports)
|
||||
americanFractionImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/us-tariff-fractions/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/us-tariff-fractions/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/us-tariff-fractions/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Pedimentos (pedimentos/imports)
|
||||
pedimentosImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/pedimentos/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/pedimentos/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/pedimentos/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Clases de Materiales (classes/imports)
|
||||
materialClassImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/classes/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/classes/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/classes/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Vehículos / Transportes (transportation/vehicles/imports)
|
||||
vehicleImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/transportation/vehicles/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/transportation/vehicles/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/transportation/vehicles/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Conductores (drivers/imports)
|
||||
driverImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/drivers/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/drivers/imports/${jobId}/status`),
|
||||
commit: (jobId: string) => api.post(`/v1/a76/drivers/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Trailers y Cajas (transportation/trailers/imports)
|
||||
trailerImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/transportation/trailers/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/transportation/trailers/imports/${jobId}/status`),
|
||||
commit: (jobId: string) =>
|
||||
api.post(`/v1/a76/transportation/trailers/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Transportistas (transporters/imports)
|
||||
transporterImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/transporters/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/transporters/imports/${jobId}/status`),
|
||||
commit: (jobId: string) => api.post(`/v1/a76/transporters/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for Números de parte (parts/imports)
|
||||
partNumberImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/parts/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/parts/imports/${jobId}/status`),
|
||||
commit: (jobId: string) => api.post(`/v1/a76/parts/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// CSV import for BOMs (boms/imports)
|
||||
bomImports: {
|
||||
upload: (file: File, companyId: number) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return fetchApi(
|
||||
`/v1/a76/boms/imports/upload?company_id=${companyId}`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
},
|
||||
status: (jobId: string) => api.get(`/v1/a76/boms/imports/${jobId}/status`),
|
||||
commit: (jobId: string) => api.post(`/v1/a76/boms/imports/${jobId}/commit`, {})
|
||||
},
|
||||
|
||||
// Generic request for custom needs (like file uploads)
|
||||
request: <T = any>(endpoint: string, options: RequestInit = {}) => fetchApi<T>(endpoint, options)
|
||||
};
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
/**
|
||||
* Servicio de autenticación con Keycloak
|
||||
*
|
||||
* Seguridad de tokens:
|
||||
* - access_token → en memoria (authStore) + cookie no-HttpOnly (para SSR)
|
||||
* - refresh_token → cookie HttpOnly únicamente (JS nunca lo lee directamente)
|
||||
* - El refresh se hace server-side via /api-sveltekit/auth/silent-refresh
|
||||
* - NO se usa localStorage para tokens
|
||||
*/
|
||||
|
||||
import Keycloak from 'keycloak-js';
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Tipos
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -22,51 +32,51 @@ export interface AuthState {
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Configuración de Keycloak
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
const keycloakConfig = {
|
||||
url: import.meta.env.VITE_KEYCLOAK_URL,
|
||||
realm: import.meta.env.VITE_KEYCLOAK_REALM,
|
||||
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID
|
||||
};
|
||||
|
||||
// Instancia de Keycloak
|
||||
let keycloakInstance: Keycloak | null = null;
|
||||
|
||||
// Helper para obtener cookies
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Cookie helpers (solo para access_token no-HttpOnly)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Lee el valor de una cookie no-HttpOnly */
|
||||
const getCookie = (name: string): string | null => {
|
||||
if (!browser) return null;
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift() || null;
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift() ?? null;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper para establecer cookies con las opciones correctas según el entorno
|
||||
/** Escribe una cookie no-HttpOnly */
|
||||
const setCookie = (name: string, value: string, days: number = 7) => {
|
||||
if (!browser) return;
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setDate(expirationDate.getDate() + days);
|
||||
|
||||
// En desarrollo (localhost), no usar Secure flag
|
||||
const isSecure = window.location.protocol === 'https:';
|
||||
const secureFlag = isSecure ? '; Secure' : '';
|
||||
|
||||
const cookieString = `${name}=${value}; path=/; expires=${expirationDate.toUTCString()}; SameSite=Lax${secureFlag}`;
|
||||
document.cookie = cookieString;
|
||||
|
||||
// Verificar que se estableció
|
||||
const verification = getCookie(name);
|
||||
const exp = new Date();
|
||||
exp.setDate(exp.getDate() + days);
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
||||
document.cookie = `${name}=${value}; path=/; expires=${exp.toUTCString()}; SameSite=Lax${secure}`;
|
||||
};
|
||||
|
||||
// Helper para eliminar cookies
|
||||
/** Elimina una cookie */
|
||||
const deleteCookie = (name: string) => {
|
||||
if (!browser) return;
|
||||
const isSecure = window.location.protocol === 'https:';
|
||||
const secureFlag = isSecure ? '; Secure' : '';
|
||||
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secureFlag}`;
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
||||
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax${secure}`;
|
||||
};
|
||||
|
||||
// Store de autenticación
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Auth store (tokens solo en memoria)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
const createAuthStore = () => {
|
||||
const { subscribe, set, update } = writable<AuthState>({
|
||||
isAuthenticated: false,
|
||||
@@ -78,16 +88,16 @@ const createAuthStore = () => {
|
||||
return {
|
||||
subscribe,
|
||||
setAuthenticated: (authenticated: boolean) =>
|
||||
update((state) => ({ ...state, isAuthenticated: authenticated })),
|
||||
update((s) => ({ ...s, isAuthenticated: authenticated })),
|
||||
setLoading: (loading: boolean) =>
|
||||
update((state) => ({ ...state, isLoading: loading })),
|
||||
setUser: (user: User | null) => update((state) => ({ ...state, user })),
|
||||
setToken: (token: string | null) => update((state) => ({ ...state, token })),
|
||||
setTokens: (accessToken: string, refreshToken?: string) => {
|
||||
update((state) => ({ ...state, token: accessToken }));
|
||||
if (browser && refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
update((s) => ({ ...s, isLoading: loading })),
|
||||
setUser: (user: User | null) => update((s) => ({ ...s, user })),
|
||||
setToken: (token: string | null) => update((s) => ({ ...s, token })),
|
||||
/** ⚠️ Los tokens ya NO se guardan en localStorage; solo en memoria. */
|
||||
setTokens: (accessToken: string, _refreshToken?: string) => {
|
||||
update((s) => ({ ...s, token: accessToken }));
|
||||
// El refresh_token llega en cookie HttpOnly desde el servidor;
|
||||
// el cliente no lo almacena ni lo lee en ningún momento.
|
||||
},
|
||||
reset: () =>
|
||||
set({
|
||||
@@ -101,14 +111,17 @@ const createAuthStore = () => {
|
||||
|
||||
export const authStore = createAuthStore();
|
||||
|
||||
// Derived store para verificar si está autenticado
|
||||
export const isAuthenticated = derived(authStore, ($auth) => $auth.isAuthenticated);
|
||||
export const isAuthenticated = derived(authStore, ($a) => $a.isAuthenticated);
|
||||
export const currentUser = derived(authStore, ($a) => $a.user);
|
||||
|
||||
// Derived store para obtener el usuario
|
||||
export const currentUser = derived(authStore, ($auth) => $auth.user);
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Inicialización
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inicializa la autenticación (Keycloak o token-based)
|
||||
* Inicializa el estado de autenticación en el cliente.
|
||||
* - Si hay un access_token en la cookie no-HttpOnly, lo usa.
|
||||
* - En cualquier caso intenta inicializar Keycloak JS (para el flujo SSO).
|
||||
*/
|
||||
export const initAuth = async (): Promise<boolean> => {
|
||||
if (!browser) return false;
|
||||
@@ -116,38 +129,28 @@ export const initAuth = async (): Promise<boolean> => {
|
||||
try {
|
||||
authStore.setLoading(true);
|
||||
|
||||
// Primero intentar restaurar sesión desde localStorage
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
authStore.setToken(token);
|
||||
// Restaurar token desde cookie no-HttpOnly (password login flow)
|
||||
const cookieToken = getCookie('access_token');
|
||||
if (cookieToken) {
|
||||
authStore.setToken(cookieToken);
|
||||
authStore.setAuthenticated(true);
|
||||
|
||||
// Sincronizar con cookies si no existe
|
||||
const cookieToken = getCookie('access_token');
|
||||
if (!cookieToken) {
|
||||
setCookie('access_token', token);
|
||||
}
|
||||
|
||||
await loadUserInfo(token);
|
||||
await loadUserInfo(cookieToken).catch(() => {});
|
||||
authStore.setLoading(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si no hay token local, intentar con Keycloak
|
||||
await initKeycloak();
|
||||
|
||||
// Sin token local, intentar Keycloak JS (flujo SSO)
|
||||
const authenticated = await initKeycloak();
|
||||
authStore.setLoading(false);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error inicializando autenticación:', error);
|
||||
return authenticated;
|
||||
} catch (err) {
|
||||
console.error('[auth] Error en initAuth:', err);
|
||||
authStore.setLoading(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Inicializa Keycloak
|
||||
*/
|
||||
/** Inicializa Keycloak JS para el flujo SSO con PKCE */
|
||||
export const initKeycloak = async (): Promise<boolean> => {
|
||||
if (!browser) return false;
|
||||
|
||||
@@ -163,22 +166,18 @@ export const initKeycloak = async (): Promise<boolean> => {
|
||||
|
||||
if (authenticated) {
|
||||
await updateAuthState();
|
||||
setupTokenRefresh();
|
||||
setupKeycloakTokenHooks();
|
||||
}
|
||||
|
||||
return authenticated;
|
||||
} catch (error) {
|
||||
console.error('Error inicializando Keycloak:', error);
|
||||
} catch (err) {
|
||||
console.error('[auth] Error inicializando Keycloak:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Variable para rastrear el tenant anterior
|
||||
let previousTenantId: number | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Actualiza el estado de autenticación con los datos de Keycloak
|
||||
*/
|
||||
const updateAuthState = async () => {
|
||||
if (!keycloakInstance?.authenticated) {
|
||||
authStore.reset();
|
||||
@@ -187,22 +186,22 @@ const updateAuthState = async () => {
|
||||
|
||||
try {
|
||||
const profile = await keycloakInstance.loadUserProfile();
|
||||
const token = keycloakInstance.token || null;
|
||||
const tokenParsed = keycloakInstance.tokenParsed as any;
|
||||
const token = keycloakInstance.token ?? null;
|
||||
const parsed = keycloakInstance.tokenParsed as any;
|
||||
|
||||
const roles = tokenParsed?.realm_access?.roles || [];
|
||||
const tenantId = tokenParsed?.tenant_id || tokenParsed?.attributes?.tenant_id;
|
||||
const newTenantId = tenantId ? parseInt(tenantId) : undefined;
|
||||
const roles: string[] = parsed?.realm_access?.roles ?? [];
|
||||
const tenantId: number | undefined = parsed?.tenant_id
|
||||
? parseInt(parsed.tenant_id)
|
||||
: undefined;
|
||||
|
||||
// Detectar si cambió el tenant
|
||||
const tenantChanged = previousTenantId !== undefined && previousTenantId !== newTenantId;
|
||||
const tenantChanged = previousTenantId !== undefined && previousTenantId !== tenantId;
|
||||
|
||||
const user: User = {
|
||||
id: profile.id || '',
|
||||
username: profile.username || '',
|
||||
id: profile.id ?? '',
|
||||
username: profile.username ?? '',
|
||||
email: profile.email,
|
||||
name: `${profile.firstName || ''} ${profile.lastName || ''}`.trim(),
|
||||
tenantId: newTenantId,
|
||||
name: `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim(),
|
||||
tenantId,
|
||||
roles
|
||||
};
|
||||
|
||||
@@ -210,71 +209,83 @@ const updateAuthState = async () => {
|
||||
authStore.setUser(user);
|
||||
authStore.setToken(token);
|
||||
|
||||
// Si cambió el tenant, limpiar el store de compañías
|
||||
if (tenantChanged && browser) {
|
||||
try {
|
||||
const { companyStore } = await import('./stores/company.svelte');
|
||||
companyStore.clear();
|
||||
} catch (error) {
|
||||
console.error('Error al limpiar store de compañías:', error);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Actualizar el tenant anterior
|
||||
previousTenantId = newTenantId;
|
||||
} catch (error) {
|
||||
console.error('Error actualizando estado de autenticación:', error);
|
||||
previousTenantId = tenantId;
|
||||
} catch (err) {
|
||||
console.error('[auth] Error actualizando estado:', err);
|
||||
authStore.reset();
|
||||
}
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Keycloak JS token hooks (solo para el flujo SSO)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Configura el refresh automático del token
|
||||
* Configura los callbacks de Keycloak JS para notificar al SessionManager
|
||||
* sobre cambios de token y eventos de sesión SSO.
|
||||
*/
|
||||
const setupTokenRefresh = () => {
|
||||
const setupKeycloakTokenHooks = () => {
|
||||
if (!keycloakInstance) return;
|
||||
|
||||
// Refrescar token cada 60 segundos si está cerca de expirar
|
||||
keycloakInstance.onTokenExpired = () => {
|
||||
keycloakInstance
|
||||
?.updateToken(70)
|
||||
.then((refreshed) => {
|
||||
if (refreshed) {
|
||||
authStore.setToken(keycloakInstance?.token || null);
|
||||
if (refreshed && keycloakInstance?.token) {
|
||||
authStore.setToken(keycloakInstance.token);
|
||||
import('./session-manager')
|
||||
.then(({ getSessionManager }) => {
|
||||
getSessionManager()?.updateToken(keycloakInstance!.token!);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.error('Error refrescando token');
|
||||
logout();
|
||||
console.error('[auth] No se pudo refrescar el token de Keycloak');
|
||||
void logout();
|
||||
});
|
||||
};
|
||||
|
||||
keycloakInstance.onAuthRefreshSuccess = () => {
|
||||
if (keycloakInstance?.token) authStore.setToken(keycloakInstance.token);
|
||||
};
|
||||
|
||||
keycloakInstance.onAuthRefreshError = () => {
|
||||
console.error('[auth] Error en refresh de Keycloak — cerrando sesión');
|
||||
void logout();
|
||||
};
|
||||
|
||||
keycloakInstance.onAuthLogout = () => {
|
||||
authStore.reset();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Inicia sesión con Keycloak (OAuth flow)
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Login
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Inicia sesión con Keycloak (OAuth redirect flow) */
|
||||
export const loginWithKeycloak = async (tenantSlug?: string) => {
|
||||
if (!keycloakInstance) {
|
||||
console.error('Keycloak no está inicializado');
|
||||
console.error('[auth] Keycloak no está inicializado');
|
||||
return;
|
||||
}
|
||||
|
||||
const options: any = {
|
||||
redirectUri: window.location.origin + '/callback'
|
||||
};
|
||||
|
||||
if (tenantSlug) {
|
||||
options.loginHint = tenantSlug;
|
||||
}
|
||||
|
||||
const options: any = { redirectUri: window.location.origin + '/callback' };
|
||||
if (tenantSlug) options.loginHint = tenantSlug;
|
||||
await keycloakInstance.login(options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Inicia sesión con credenciales (username/password)
|
||||
* Nota: Esta función ya no se usa directamente desde el login form,
|
||||
* el login ahora se hace mediante form actions del servidor.
|
||||
* Se mantiene para compatibilidad con SSO y otros flujos.
|
||||
* Login con usuario/contraseña (legacy — el login principal es via form action del servidor).
|
||||
* Los tokens se guardan en cookies (vía setCookie) y en memoria (authStore).
|
||||
* NO se guardan en localStorage.
|
||||
*/
|
||||
export const login = async (credentials: {
|
||||
username: string;
|
||||
@@ -282,235 +293,185 @@ export const login = async (credentials: {
|
||||
tenant_slug: string;
|
||||
}): Promise<{ success: boolean; error?: string; data?: any }> => {
|
||||
try {
|
||||
// Usar la API centralizada
|
||||
const { api } = await import('./api');
|
||||
const response = await api.auth.login(credentials);
|
||||
|
||||
// Si hay error en la respuesta
|
||||
if (response.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.error
|
||||
};
|
||||
return { success: false, error: response.error };
|
||||
}
|
||||
|
||||
// Guardar tokens y actualizar estado
|
||||
const loginData = response.data;
|
||||
if (loginData?.access_token) {
|
||||
// Guardar en memoria y en cookie no-HttpOnly para SSR
|
||||
authStore.setToken(loginData.access_token);
|
||||
authStore.setAuthenticated(true);
|
||||
|
||||
// Guardar también en localStorage para persistencia
|
||||
if (browser) {
|
||||
localStorage.setItem('access_token', loginData.access_token);
|
||||
if (loginData.refresh_token) {
|
||||
localStorage.setItem('refresh_token', loginData.refresh_token);
|
||||
}
|
||||
|
||||
// Guardar en cookies para que el servidor pueda acceder
|
||||
setCookie('access_token', loginData.access_token);
|
||||
if (loginData.refresh_token) {
|
||||
setCookie('refresh_token', loginData.refresh_token);
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar información del usuario
|
||||
setCookie('access_token', loginData.access_token);
|
||||
// El refresh_token llega en cookie HttpOnly desde el servidor.
|
||||
// NO lo guardamos en JS.
|
||||
await loadUserInfo(loginData.access_token);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: loginData
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error en login:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Error de conexión con el servidor'
|
||||
};
|
||||
return { success: true, data: loginData };
|
||||
} catch (err) {
|
||||
console.error('[auth] Error en login:', err);
|
||||
return { success: false, error: 'Error de conexión con el servidor' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Carga la información del usuario desde el token
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// User info
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
const loadUserInfo = async (token: string) => {
|
||||
try {
|
||||
// Guardar temporalmente el token para que api.ts lo use
|
||||
authStore.setToken(token);
|
||||
|
||||
// Usar la API centralizada
|
||||
const { api } = await import('./api');
|
||||
const response = await api.auth.me();
|
||||
|
||||
if (response.data) {
|
||||
const data = response.data;
|
||||
const user: User = {
|
||||
id: data.sub || '',
|
||||
username: data.preferred_username || data.username || '',
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
tenantId: data.tenant_id,
|
||||
roles: data.realm_access?.roles || []
|
||||
};
|
||||
authStore.setUser(user);
|
||||
const d = response.data;
|
||||
authStore.setUser({
|
||||
id: d.sub ?? '',
|
||||
username: d.preferred_username ?? d.username ?? '',
|
||||
email: d.email,
|
||||
name: d.name,
|
||||
tenantId: d.tenant_id,
|
||||
roles: d.realm_access?.roles ?? []
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando información del usuario:', error);
|
||||
} catch (err) {
|
||||
console.error('[auth] Error cargando info del usuario:', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cierra sesión
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Logout
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
export const logout = async () => {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
// Capturar tokens antes de limpiar nada
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
// Detener el SessionManager
|
||||
try {
|
||||
const { destroySessionManager } = await import('./session-manager');
|
||||
destroySessionManager();
|
||||
} catch {}
|
||||
|
||||
// Limpiar store de compañías
|
||||
try {
|
||||
const { companyStore } = await import('./stores/company.svelte');
|
||||
companyStore.clear();
|
||||
} catch (error) {
|
||||
console.error('Error al limpiar store de compañías:', error);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Limpiar estado local
|
||||
// Limpiar estado en memoria
|
||||
authStore.reset();
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
|
||||
// Eliminar cookie no-HttpOnly del access_token
|
||||
deleteCookie('access_token');
|
||||
deleteCookie('refresh_token');
|
||||
// La cookie HttpOnly del refresh_token la limpia el servidor
|
||||
|
||||
// Si hay instancia de Keycloak, hacer logout de Keycloak
|
||||
// Logout de Keycloak JS si estaba autenticado con SSO
|
||||
if (keycloakInstance?.authenticated) {
|
||||
// Primero notificamos al servidor para limpieza de cookies (SvelteKit)
|
||||
try {
|
||||
await fetch('/logout', {
|
||||
method: 'POST'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Error calling server logout:", e);
|
||||
}
|
||||
|
||||
await fetch('/logout', { method: 'POST' });
|
||||
} catch {}
|
||||
await keycloakInstance.logout({
|
||||
redirectUri: window.location.origin + '/login'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Llamar al endpoint del servidor para limpiar cookies de SvelteKit
|
||||
// Usar un formulario para hacer POST y permitir la redirección
|
||||
// Para login con password: POST al logout route del servidor
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/logout';
|
||||
|
||||
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error durante logout:', error);
|
||||
// Asegurar que se redirija al login aunque haya error
|
||||
} catch (err) {
|
||||
console.error('[auth] Error durante logout:', err);
|
||||
window.location.href = '/login';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifica si el usuario tiene un rol específico
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Token accessors
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
export const hasRole = (role: string): boolean => {
|
||||
if (!keycloakInstance?.authenticated) return false;
|
||||
return keycloakInstance.hasRealmRole(role);
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtiene el token de acceso actual
|
||||
*/
|
||||
/** Obtiene el access token desde memoria (Keycloak JS o authStore) */
|
||||
export const getToken = (): string | null => {
|
||||
// Intentar obtener de Keycloak primero
|
||||
if (keycloakInstance?.token) {
|
||||
return keycloakInstance.token;
|
||||
}
|
||||
// Prioridad 1: Keycloak JS en memoria
|
||||
if (keycloakInstance?.token) return keycloakInstance.token;
|
||||
|
||||
// Si no, intentar de localStorage
|
||||
if (browser) {
|
||||
let token = localStorage.getItem('access_token');
|
||||
// Prioridad 2: authStore en memoria
|
||||
let token: string | null = null;
|
||||
const unsub = authStore.subscribe((s) => { token = s.token; });
|
||||
unsub();
|
||||
if (token) return token;
|
||||
|
||||
// Si no hay token en localStorage, intentar de las cookies
|
||||
if (!token) {
|
||||
token = getCookie('access_token');
|
||||
// Si lo encontramos en cookies, sincronizarlo a localStorage
|
||||
if (token) {
|
||||
localStorage.setItem('access_token', token);
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
// Prioridad 3: cookie no-HttpOnly (fallback para acceso inicial antes del onMount)
|
||||
if (browser) return getCookie('access_token');
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresca el access token usando el refresh token
|
||||
* Refresca el access token usando el endpoint server-side seguro.
|
||||
* El servidor lee el refresh_token de la cookie HttpOnly.
|
||||
* @returns true si el refresh fue exitoso
|
||||
*/
|
||||
export const refreshAccessToken = async (): Promise<boolean> => {
|
||||
if (!browser) return false;
|
||||
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (!refreshToken) {
|
||||
return false;
|
||||
// Con Keycloak JS activo, usar su mecanismo nativo
|
||||
if (keycloakInstance?.authenticated) {
|
||||
try {
|
||||
const refreshed = await keycloakInstance.updateToken(70);
|
||||
if (refreshed || keycloakInstance.token) {
|
||||
authStore.setToken(keycloakInstance.token ?? null);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
await logout();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Flujo de contraseña: usar el endpoint server-side seguro
|
||||
try {
|
||||
const { api } = await import('./api');
|
||||
const response = await api.auth.refresh(refreshToken);
|
||||
const resp = await fetch('/api-sveltekit/auth/silent-refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (response.error || !response.data) {
|
||||
console.error('Failed to refresh token:', response.error);
|
||||
// Si falla el refresh, hacer logout
|
||||
if (!resp.ok) {
|
||||
await logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Actualizar tokens
|
||||
const newAccessToken = response.data.access_token;
|
||||
const newRefreshToken = response.data.refresh_token;
|
||||
|
||||
authStore.setToken(newAccessToken);
|
||||
localStorage.setItem('access_token', newAccessToken);
|
||||
|
||||
if (newRefreshToken) {
|
||||
localStorage.setItem('refresh_token', newRefreshToken);
|
||||
const data = await resp.json() as { access_token?: string };
|
||||
if (data.access_token) {
|
||||
authStore.setToken(data.access_token);
|
||||
setCookie('access_token', data.access_token);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Actualizar también la cookie
|
||||
setCookie('access_token', newAccessToken);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
await logout();
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.error('[auth] Error en refreshAccessToken:', err);
|
||||
}
|
||||
|
||||
await logout();
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtiene el refresh token
|
||||
*/
|
||||
/** @deprecated El refresh_token ya no se expone en JS. */
|
||||
export const getRefreshToken = (): string | null => {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem('refresh_token');
|
||||
console.warn('[auth] getRefreshToken() está deprecado — el refresh_token no se expone en JS.');
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtiene la instancia de Keycloak
|
||||
*/
|
||||
export const getKeycloakInstance = (): Keycloak | null => {
|
||||
return keycloakInstance;
|
||||
};
|
||||
export const getKeycloakInstance = (): Keycloak | null => keycloakInstance;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { CircleAlert } from 'lucide-svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
agentsCount = 0,
|
||||
clientsCount = 0,
|
||||
onAccept,
|
||||
onCancel
|
||||
}: {
|
||||
open?: boolean;
|
||||
agentsCount?: number;
|
||||
clientsCount?: number;
|
||||
onAccept?: () => void;
|
||||
onCancel?: () => void;
|
||||
} = $props();
|
||||
|
||||
const showModal = $derived(agentsCount === 0 || clientsCount === 0);
|
||||
|
||||
const message = $derived(
|
||||
agentsCount === 0 && clientsCount === 0
|
||||
? 'No hay Agentes aduanales ni Clientes registrados. Debes darlos de alta para poder trabajar en este módulo.'
|
||||
: agentsCount === 0
|
||||
? 'No hay Agentes aduanales registrados. Debes darlos de alta para poder trabajar en este módulo.'
|
||||
: 'No hay Clientes registrados. Debes darlos de alta para poder trabajar en este módulo.'
|
||||
);
|
||||
|
||||
function handleOpenChange(newOpen: boolean) {
|
||||
open = newOpen;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
onCancel?.();
|
||||
}
|
||||
|
||||
function handleAccept() {
|
||||
onAccept?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<AlertDialog.Root bind:open onOpenChange={handleOpenChange}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<div class="flex items-center gap-3">
|
||||
<CircleAlert class="h-6 w-6 shrink-0 text-amber-500" />
|
||||
<AlertDialog.Title>Aviso</AlertDialog.Title>
|
||||
</div>
|
||||
<AlertDialog.Description class="space-y-3 pt-1">
|
||||
<p>{message}</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Puedes registrarlos en
|
||||
<a
|
||||
href="/dashboard/customs_brokers"
|
||||
class="font-medium text-primary underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
Agentes Aduanales
|
||||
</a>
|
||||
y
|
||||
<a
|
||||
href="/dashboard/clients_and_providers"
|
||||
class="font-medium text-primary underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
Clientes y Proveedores
|
||||
</a>.
|
||||
</p>
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel onclick={handleCancel}>Cancelar</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={handleAccept}>Aceptar</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -143,17 +143,53 @@
|
||||
|
||||
{#if scanResults.error_count > 0}
|
||||
<div
|
||||
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3"
|
||||
class="rounded-md bg-destructive/5 border border-destructive/10 p-4 flex items-start gap-3 mb-4"
|
||||
>
|
||||
<XCircle class="w-5 h-5 text-destructive mt-0.5 shrink-0" />
|
||||
<div class="text-sm text-destructive-foreground/90">
|
||||
<p class="font-semibold mb-1">Se detectaron problemas en el archivo</p>
|
||||
<p>
|
||||
Las filas con errores serán omitidas automáticamente. Solo se importarán los
|
||||
registros válidos.
|
||||
Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para
|
||||
importar solo las filas válidas (las erróneas se omitirán).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if scanResults.errors && scanResults.errors.length > 0}
|
||||
<div class="border rounded-lg overflow-hidden shadow-sm">
|
||||
<div class="bg-muted/50 px-4 py-2 border-b flex justify-between items-center">
|
||||
<h5 class="text-xs font-bold text-foreground uppercase tracking-wide">
|
||||
Detalle de errores (para corregir en el CSV)
|
||||
</h5>
|
||||
<span
|
||||
class="text-[10px] bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border"
|
||||
>
|
||||
{scanResults.errors.length} error(es)
|
||||
</span>
|
||||
</div>
|
||||
<div class="max-h-60 overflow-y-auto bg-card relative">
|
||||
<table class="w-full text-xs text-left">
|
||||
<thead
|
||||
class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-4 py-2 w-16">Línea</th>
|
||||
<th class="px-4 py-2 w-40">Columna</th>
|
||||
<th class="px-4 py-2">Mensaje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
{#each scanResults.errors as err}
|
||||
<tr class="hover:bg-muted/30 transition-colors">
|
||||
<td class="px-4 py-2 font-mono text-muted-foreground">{err.line}</td>
|
||||
<td class="px-4 py-2 font-mono font-medium text-foreground">{err.col || '-'}</td>
|
||||
<td class="px-4 py-2 text-destructive">{err.msg || '-'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="rounded-md bg-primary/5 border border-primary/10 p-4 flex items-start gap-3">
|
||||
<CheckCircle2 class="w-5 h-5 text-primary mt-0.5 shrink-0" />
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { UploadCloud, Lock } from 'lucide-svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let {
|
||||
items,
|
||||
@@ -88,23 +89,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent, item: CsvUploadItem) {
|
||||
async function handleContextMenu(e: MouseEvent, item: CsvUploadItem) {
|
||||
if (item.disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!item.templateUrl) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = item.templateUrl;
|
||||
link.download = item.templateUrl.split('/').pop() || 'plantilla.xls';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
if (!item.templateId) return;
|
||||
|
||||
toast.info(`Descargando plantilla para ${item.title}...`);
|
||||
try {
|
||||
toast.info(`Descargando plantilla para ${item.title}...`);
|
||||
const { blob, filename } = await api.getCsvTemplateDownload(item.templateId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(`Plantilla descargada: ${filename}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error al descargar la plantilla');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -123,7 +132,7 @@
|
||||
ondragover={(e) => handleDragOver(e, item.disabled)}
|
||||
ondrop={(e) => handleDrop(e, item)}
|
||||
oncontextmenu={(e) => handleContextMenu(e, item)}
|
||||
roles="button"
|
||||
role="button"
|
||||
tabindex={item.disabled ? -1 : 0}
|
||||
onclick={() => handleClick(item.id, item.disabled)}
|
||||
onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)}
|
||||
|
||||
@@ -8,6 +8,20 @@ export type { CustomsBroker };
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<CustomsBroker>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
cell: ({ row }) => {
|
||||
const idSnippet = createRawSnippet<[{ id: number }]>((getId) => {
|
||||
const { id } = getId();
|
||||
return {
|
||||
render: () =>
|
||||
`<code class="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm">${id}</code>`
|
||||
};
|
||||
});
|
||||
return renderSnippet(idSnippet, { id: row.original.id });
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "broker_key",
|
||||
header: "Clave",
|
||||
|
||||
@@ -275,7 +275,7 @@
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="postal_code">C.P.</Label>
|
||||
<Input id="postal_code" bind:value={formData.postal_code} disabled={loading} />
|
||||
<Input id="postal_code" bind:value={formData.postal_code} disabled={loading} oninput={(e) => { formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); }} />
|
||||
</div>
|
||||
<div class="space-y-2 col-span-2">
|
||||
<Label for="city">Ciudad</Label>
|
||||
|
||||
@@ -318,6 +318,7 @@
|
||||
placeholder="C.P."
|
||||
maxlength={15}
|
||||
disabled={loading}
|
||||
oninput={(e) => { formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -119,7 +119,14 @@
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) throw new Error('No hay una compañía seleccionada');
|
||||
if (!formData.date) throw new Error('La fecha es requerida');
|
||||
if (formData.value === null) throw new Error('El valor es requerido');
|
||||
if (
|
||||
formData.value === null ||
|
||||
formData.value === undefined ||
|
||||
String(formData.value).trim() === ''
|
||||
)
|
||||
throw new Error('El tipo de cambio es requerido');
|
||||
if (Number(formData.value) <= 0)
|
||||
throw new Error('El tipo de cambio debe ser un valor mayor a 0');
|
||||
|
||||
showConfirmation = true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -181,6 +181,20 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar tipo de cambio
|
||||
if (
|
||||
formData.exchange_rate === null ||
|
||||
formData.exchange_rate === undefined ||
|
||||
String(formData.exchange_rate).trim() === ''
|
||||
) {
|
||||
error = 'El tipo de cambio es requerido (pestaña Financieros)';
|
||||
return;
|
||||
}
|
||||
if (Number(formData.exchange_rate) <= 0) {
|
||||
error = 'El tipo de cambio debe ser mayor a 0 (pestaña Financieros)';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
|
||||
@@ -123,11 +123,13 @@
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open={helpStore.isOpen}>
|
||||
<Sheet.Trigger
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
<Sheet.Trigger asChild>
|
||||
<button
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
</button>
|
||||
</Sheet.Trigger>
|
||||
<Sheet.Content side="right" class="w-[400px] sm:w-[540px]">
|
||||
<Sheet.Header>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
// Group local shortcuts
|
||||
let localShortcuts = $derived($store.shortcuts);
|
||||
|
||||
let globalList = $state<HTMLDivElement>();
|
||||
let localList = $state<HTMLDivElement>();
|
||||
let modalRef = $state<HTMLDivElement>();
|
||||
let globalList = $state<HTMLDivElement | undefined>();
|
||||
let localList = $state<HTMLDivElement | undefined>();
|
||||
let modalRef = $state<HTMLDivElement | undefined>();
|
||||
|
||||
function handleArrowScroll(event: KeyboardEvent, target: HTMLDivElement | undefined) {
|
||||
if (!target) return;
|
||||
@@ -50,7 +50,8 @@
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 text-gray-900 shadow-2xl dark:bg-gray-900 dark:text-gray-100"
|
||||
class="w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-900 text-gray-900 dark:text-gray-100 max-h-[80vh] overflow-y-auto"
|
||||
role="document"
|
||||
bind:this={modalRef}
|
||||
role="presentation"
|
||||
onkeydown={handleFocusTrap}
|
||||
@@ -89,7 +90,9 @@
|
||||
</h3>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="max-h-64 space-y-2 overflow-y-auto pr-1"
|
||||
class="space-y-2 max-h-64 overflow-y-auto pr-1"
|
||||
role="region"
|
||||
aria-label="Global Navigation shortcuts"
|
||||
bind:this={globalList}
|
||||
tabindex="-1"
|
||||
role="region"
|
||||
@@ -128,7 +131,9 @@
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="max-h-64 space-y-2 overflow-y-auto pr-1"
|
||||
class="space-y-2 max-h-64 overflow-y-auto pr-1"
|
||||
role="region"
|
||||
aria-label="Active Actions shortcuts"
|
||||
bind:this={localList}
|
||||
tabindex="-1"
|
||||
role="region"
|
||||
|
||||
128
frontend/src/lib/components/session-timeout-warning.svelte
Normal file
128
frontend/src/lib/components/session-timeout-warning.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import {
|
||||
SESSION_WARNING_EVENT,
|
||||
SESSION_EXPIRED_EVENT,
|
||||
SESSION_EXTENDED_EVENT,
|
||||
getSessionManager
|
||||
} from '$lib/session-manager';
|
||||
import type { SessionWarningDetail } from '$lib/session-manager';
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
let open = $state(false);
|
||||
let remainingSeconds = $state(300);
|
||||
let countdownId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
function formatTime(secs: number): string {
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function clearCountdown() {
|
||||
if (countdownId !== null) {
|
||||
clearInterval(countdownId);
|
||||
countdownId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
clearCountdown();
|
||||
countdownId = setInterval(() => {
|
||||
remainingSeconds = Math.max(0, remainingSeconds - 1);
|
||||
if (remainingSeconds === 0) clearCountdown();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ─── Event handlers ───────────────────────────────────────────────────────
|
||||
function onWarning(e: Event) {
|
||||
const { remainingMs } = (e as CustomEvent<SessionWarningDetail>).detail;
|
||||
remainingSeconds = Math.floor(remainingMs / 1000);
|
||||
open = true;
|
||||
startCountdown();
|
||||
}
|
||||
|
||||
function onExpired() {
|
||||
open = false;
|
||||
clearCountdown();
|
||||
}
|
||||
|
||||
function onExtended() {
|
||||
open = false;
|
||||
clearCountdown();
|
||||
}
|
||||
|
||||
// ─── User actions ─────────────────────────────────────────────────────────
|
||||
function continueSession() {
|
||||
const mgr = getSessionManager();
|
||||
mgr?.extendSession();
|
||||
open = false;
|
||||
clearCountdown();
|
||||
}
|
||||
|
||||
function logoutNow() {
|
||||
open = false;
|
||||
clearCountdown();
|
||||
// Dispara el evento de sesión expirada para que el layout gestione el logout
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SESSION_EXPIRED_EVENT, { detail: { reason: 'manual' } })
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
if (!browser) return;
|
||||
window.addEventListener(SESSION_WARNING_EVENT, onWarning);
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, onExpired);
|
||||
window.addEventListener(SESSION_EXTENDED_EVENT, onExtended);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (!browser) return;
|
||||
clearCountdown();
|
||||
window.removeEventListener(SESSION_WARNING_EVENT, onWarning);
|
||||
window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired);
|
||||
window.removeEventListener(SESSION_EXTENDED_EVENT, onExtended);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
session-timeout-warning.svelte
|
||||
Diálogo que avisa al usuario cuando su sesión está a punto de expirar
|
||||
por inactividad. Se controla completamente a través de eventos DOM.
|
||||
-->
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
class="fixed left-1/2 top-1/2 z-[9999] w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-background p-6 shadow-xl"
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2 text-lg font-semibold">
|
||||
⚠️ Sesión por expirar
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="mt-2 text-sm text-muted-foreground">
|
||||
Tu sesión cerrará automáticamente por inactividad en
|
||||
<span class="font-mono font-bold text-foreground">
|
||||
{formatTime(remainingSeconds)}
|
||||
</span>.
|
||||
<br />
|
||||
¿Deseas continuar trabajando?
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Footer class="mt-6 flex gap-3">
|
||||
<Button variant="outline" class="flex-1" onclick={logoutNow}>
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
<Button class="flex-1" onclick={continueSession}>
|
||||
Continuar sesión
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -85,7 +85,6 @@
|
||||
bind:this={searchInputRef}
|
||||
value={searchQuery}
|
||||
placeholder={searchPlaceholder}
|
||||
autofocus={autoFocusSearch}
|
||||
class="placeholder:text-muted-foreground flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
|
||||
oninput={(event) => updateQuery((event.currentTarget as HTMLInputElement).value)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -1,366 +1,388 @@
|
||||
import {
|
||||
User,
|
||||
Users,
|
||||
FileText,
|
||||
Truck,
|
||||
Container,
|
||||
Ship,
|
||||
Plane,
|
||||
Package,
|
||||
Briefcase,
|
||||
Globe,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Hash,
|
||||
MapPin,
|
||||
ShieldCheck,
|
||||
FileDigit,
|
||||
Scale,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
export interface CsvUploadItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: any;
|
||||
group?: string; // For grouping within a tab
|
||||
modelTarget?: string; // The backend model this maps to
|
||||
description?: string;
|
||||
templateUrl?: string; // Path to the template file in static/
|
||||
disabled?: boolean; // New property to mark items as "Coming Soon"
|
||||
}
|
||||
|
||||
export interface CsvUploadField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'select' | 'boolean' | 'date' | 'radio';
|
||||
options?: { label: string; value: string | boolean | number }[];
|
||||
required?: boolean;
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
// Map of Tab ID -> Array of Fields
|
||||
export const tabSettings: Record<string, CsvUploadField[]> = {
|
||||
catalogos: [
|
||||
{
|
||||
name: 'mode',
|
||||
label: 'Modo de Carga',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Actualizar', value: 'update' },
|
||||
{ label: 'Reemplazar', value: 'replace' }
|
||||
],
|
||||
defaultValue: 'update'
|
||||
}
|
||||
],
|
||||
transportes: [
|
||||
{
|
||||
name: 'mode',
|
||||
label: 'Modo de Carga',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Actualizar', value: 'update' },
|
||||
{ label: 'Reemplazar', value: 'replace' }
|
||||
],
|
||||
defaultValue: 'update'
|
||||
}
|
||||
],
|
||||
importacion: [
|
||||
{
|
||||
name: 'autonumber_remesas',
|
||||
label: 'Autonumerar Remesas',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
name: 'recalculate_dates',
|
||||
label: 'Recalcular Fechas',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
name: 'dateFormat',
|
||||
label: 'Formato de Fecha',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
|
||||
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
|
||||
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
|
||||
],
|
||||
defaultValue: 'dd/mm/yyyy'
|
||||
}
|
||||
],
|
||||
exportacion: [
|
||||
{
|
||||
name: 'invoice_type',
|
||||
label: 'Tipo de Factura',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'AFIJO', value: 'AFIJO' },
|
||||
{ label: 'NORMAL', value: 'NORMAL' },
|
||||
],
|
||||
defaultValue: 'AFIJO',
|
||||
},
|
||||
{
|
||||
name: 'is_regime_change',
|
||||
label: 'Es Cambio de Régimen',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: 'dateFormat',
|
||||
label: 'Formato de Fecha',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
|
||||
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
|
||||
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
|
||||
],
|
||||
defaultValue: 'dd/mm/yyyy'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// --- DATA DEFINITIONS (Items only, no config) ---
|
||||
|
||||
export const catalogosConfig: CsvUploadItem[] = [
|
||||
{
|
||||
id: 'customs_brokers',
|
||||
title: 'Agentes Aduanales',
|
||||
icon: User,
|
||||
modelTarget: 'CustomsBroker',
|
||||
templateUrl: '/csv/EstructuraCatAgenteAduanal.xls'
|
||||
},
|
||||
{
|
||||
id: 'clients_providers',
|
||||
title: 'Clientes y Proveedores',
|
||||
icon: Users,
|
||||
modelTarget: 'ClientProvider',
|
||||
templateUrl: '/csv/EstructuraCatClienteProv.xls'
|
||||
},
|
||||
{
|
||||
id: 'exchange_rates',
|
||||
title: 'Tipo de Cambios',
|
||||
icon: DollarSign,
|
||||
modelTarget: 'ExchangeRate',
|
||||
templateUrl: '/csv/EstructuraCatTiposCambio.xls'
|
||||
},
|
||||
{
|
||||
id: 'american_fractions',
|
||||
title: 'Fracc. Ame.',
|
||||
icon: Globe,
|
||||
modelTarget: 'AmericanFraction',
|
||||
templateUrl: '/csv/EstructuraCatFraccAme.xls'
|
||||
},
|
||||
{
|
||||
id: 'material_classes',
|
||||
title: 'Clases de Materiales',
|
||||
icon: Package,
|
||||
modelTarget: 'MaterialClass',
|
||||
templateUrl: '/csv/EstructuraCatClasesAF.xls'
|
||||
},
|
||||
{
|
||||
id: 'items',
|
||||
title: 'Partidas (Permisos)',
|
||||
icon: FileText,
|
||||
group: 'Permisos',
|
||||
modelTarget: 'ItemPermission',
|
||||
templateUrl: '/csv/EstructuraCatPartesAF.xls'
|
||||
},
|
||||
{
|
||||
id: 'headers',
|
||||
title: 'Encabezados (Permisos)',
|
||||
icon: FileText,
|
||||
group: 'Permisos',
|
||||
modelTarget: 'HeaderPermission',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'historical_fractions',
|
||||
title: 'Fracciones Históricas',
|
||||
icon: Calendar,
|
||||
modelTarget: 'HistoricalFraction',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'pedimentos',
|
||||
title: 'Pedimentos',
|
||||
icon: FileDigit,
|
||||
modelTarget: 'Pedimento',
|
||||
templateUrl: '/csv/EstructuraCatPedimentos.xls'
|
||||
},
|
||||
];
|
||||
|
||||
export const transportesConfig: CsvUploadItem[] = [
|
||||
{
|
||||
id: 'transports',
|
||||
title: 'Transportes',
|
||||
icon: Truck,
|
||||
modelTarget: 'Transport',
|
||||
templateUrl: '/csv/EstructuraCatTransportes.xls'
|
||||
},
|
||||
{
|
||||
id: 'drivers',
|
||||
title: 'Conductores',
|
||||
icon: User,
|
||||
modelTarget: 'Driver',
|
||||
templateUrl: '/csv/EstructuraCatConductor.xls'
|
||||
},
|
||||
{
|
||||
id: 'trailers',
|
||||
title: 'Trailers y Cajas',
|
||||
icon: Container,
|
||||
modelTarget: 'Trailer',
|
||||
templateUrl: '/csv/EstructuraCatTrailers.xls'
|
||||
},
|
||||
];
|
||||
|
||||
export const importacionConfig: CsvUploadItem[] = [
|
||||
// Impo Temp
|
||||
{
|
||||
id: 'imp_temp_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Impo. Temp.',
|
||||
modelTarget: 'invoice_header',
|
||||
templateUrl: '/csv/EstructuraEncFacImpoTemp.xls'
|
||||
},
|
||||
{
|
||||
id: 'imp_temp_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Impo. Temp.',
|
||||
modelTarget: 'invoice_details',
|
||||
templateUrl: '/csv/EstructuraParFacImpoTempAF.xls'
|
||||
},
|
||||
{
|
||||
id: 'imp_temp_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Impo. Temp.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
// Impo Def
|
||||
{
|
||||
id: 'imp_def_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Impo. Def.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
templateUrl: '/csv/EstructuraEncFacImpoDef.xls'
|
||||
},
|
||||
{
|
||||
id: 'imp_def_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Impo. Def.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
templateUrl: '/csv/EstructuraParFacImpoDefAF.xls'
|
||||
},
|
||||
{
|
||||
id: 'imp_def_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Impo. Def.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
// Compras Mex
|
||||
{
|
||||
id: 'comp_mex_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Compras Mex.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'comp_mex_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Compras Mex.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'comp_mex_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Compras Mex.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const exportacionConfig: CsvUploadItem[] = [
|
||||
// Expo Def / Cam. Reg.
|
||||
{
|
||||
id: 'exp_def_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
templateUrl: '/csv/EstructuraEncFacExpoCamReg.xls'
|
||||
},
|
||||
{
|
||||
id: 'exp_def_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
templateUrl: '/csv/EstructuraParExpoCamReg.xls'
|
||||
},
|
||||
{
|
||||
id: 'exp_def_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'exp_def_nodes',
|
||||
title: 'NODES',
|
||||
icon: Briefcase,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'Nodes',
|
||||
disabled: true,
|
||||
},
|
||||
// Expo Rep
|
||||
{
|
||||
id: 'exp_rep_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Expo. Rep.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'exp_rep_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Expo. Rep.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'exp_rep_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Expo. Rep.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
// Manifiesto
|
||||
{
|
||||
id: 'manifest_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Manifiesto',
|
||||
modelTarget: 'Manifest',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
import {
|
||||
User,
|
||||
Users,
|
||||
FileText,
|
||||
Truck,
|
||||
Container,
|
||||
Ship,
|
||||
Plane,
|
||||
Package,
|
||||
Briefcase,
|
||||
Globe,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Hash,
|
||||
MapPin,
|
||||
ShieldCheck,
|
||||
FileDigit,
|
||||
Scale,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
export interface CsvUploadItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: any;
|
||||
group?: string; // For grouping within a tab
|
||||
modelTarget?: string; // The backend model this maps to
|
||||
description?: string;
|
||||
/** Backend template id for CSV download (e.g. customs_brokers, part_numbers). No physical file. */
|
||||
templateId?: string;
|
||||
disabled?: boolean; // New property to mark items as "Coming Soon"
|
||||
}
|
||||
|
||||
export interface CsvUploadField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'select' | 'boolean' | 'date' | 'radio';
|
||||
options?: { label: string; value: string | boolean | number }[];
|
||||
required?: boolean;
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
// Map of Tab ID -> Array of Fields
|
||||
export const tabSettings: Record<string, CsvUploadField[]> = {
|
||||
catalogos: [
|
||||
{
|
||||
name: 'mode',
|
||||
label: 'Modo de Carga',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Actualizar', value: 'update' },
|
||||
{ label: 'Reemplazar', value: 'replace' }
|
||||
],
|
||||
defaultValue: 'update'
|
||||
}
|
||||
],
|
||||
transportes: [
|
||||
{
|
||||
name: 'mode',
|
||||
label: 'Modo de Carga',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Actualizar', value: 'update' },
|
||||
{ label: 'Reemplazar', value: 'replace' }
|
||||
],
|
||||
defaultValue: 'update'
|
||||
}
|
||||
],
|
||||
importacion: [
|
||||
{
|
||||
name: 'autonumber_remesas',
|
||||
label: 'Autonumerar Remesas',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
name: 'recalculate_dates',
|
||||
label: 'Recalcular Fechas',
|
||||
type: 'boolean',
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
name: 'dateFormat',
|
||||
label: 'Formato de Fecha',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
|
||||
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
|
||||
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
|
||||
],
|
||||
defaultValue: 'dd/mm/yyyy'
|
||||
}
|
||||
],
|
||||
exportacion: [
|
||||
{
|
||||
name: 'invoice_type',
|
||||
label: 'Tipo de Factura',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'AFIJO', value: 'AFIJO' },
|
||||
{ label: 'NORMAL', value: 'NORMAL' },
|
||||
],
|
||||
defaultValue: 'AFIJO',
|
||||
},
|
||||
{
|
||||
name: 'is_regime_change',
|
||||
label: 'Es Cambio de Régimen',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: 'dateFormat',
|
||||
label: 'Formato de Fecha',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' },
|
||||
{ label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' },
|
||||
{ label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' }
|
||||
],
|
||||
defaultValue: 'dd/mm/yyyy'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// --- DATA DEFINITIONS (Items only, no config) ---
|
||||
|
||||
export const catalogosConfig: CsvUploadItem[] = [
|
||||
{
|
||||
id: 'customs_brokers',
|
||||
title: 'Agentes Aduanales',
|
||||
icon: User,
|
||||
modelTarget: 'CustomsBroker',
|
||||
templateId: 'customs_brokers'
|
||||
},
|
||||
{
|
||||
id: 'clients_providers',
|
||||
title: 'Clientes y Proveedores',
|
||||
icon: Users,
|
||||
modelTarget: 'ClientProvider',
|
||||
templateId: 'clients_providers'
|
||||
},
|
||||
{
|
||||
id: 'exchange_rates',
|
||||
title: 'Tipo de Cambios',
|
||||
icon: DollarSign,
|
||||
modelTarget: 'ExchangeRate',
|
||||
templateId: 'exchange_rates'
|
||||
},
|
||||
{
|
||||
id: 'american_fractions',
|
||||
title: 'Fracc. Ame.',
|
||||
icon: Globe,
|
||||
modelTarget: 'AmericanFraction',
|
||||
templateId: 'american_fractions'
|
||||
},
|
||||
{
|
||||
id: 'material_classes',
|
||||
title: 'Clases de Materiales',
|
||||
icon: Package,
|
||||
modelTarget: 'MaterialClass',
|
||||
templateId: 'material_classes'
|
||||
},
|
||||
{
|
||||
id: 'part_numbers',
|
||||
title: 'Números de parte',
|
||||
icon: Hash,
|
||||
modelTarget: 'Part',
|
||||
templateId: 'part_numbers',
|
||||
},
|
||||
{
|
||||
id: 'boms',
|
||||
title: 'BOMs',
|
||||
icon: Briefcase,
|
||||
modelTarget: 'Bom',
|
||||
templateId: 'boms',
|
||||
},
|
||||
{
|
||||
id: 'items',
|
||||
title: 'Partidas (Permisos)',
|
||||
icon: FileText,
|
||||
group: 'Permisos',
|
||||
modelTarget: 'ItemPermission',
|
||||
templateId: 'part_numbers'
|
||||
},
|
||||
{
|
||||
id: 'headers',
|
||||
title: 'Encabezados (Permisos)',
|
||||
icon: FileText,
|
||||
group: 'Permisos',
|
||||
modelTarget: 'HeaderPermission',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'historical_fractions',
|
||||
title: 'Fracciones Históricas',
|
||||
icon: Calendar,
|
||||
modelTarget: 'HistoricalFraction',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'pedimentos',
|
||||
title: 'Pedimentos',
|
||||
icon: FileDigit,
|
||||
modelTarget: 'Pedimento',
|
||||
templateId: 'pedimentos'
|
||||
},
|
||||
];
|
||||
|
||||
export const transportesConfig: CsvUploadItem[] = [
|
||||
{
|
||||
id: 'transporters',
|
||||
title: 'Transportistas',
|
||||
icon: Ship,
|
||||
modelTarget: 'Transporter',
|
||||
// No templateId: backend transporters/imports not implemented yet
|
||||
},
|
||||
{
|
||||
id: 'transports',
|
||||
title: 'Transportes',
|
||||
icon: Truck,
|
||||
modelTarget: 'Transport',
|
||||
templateId: 'transports'
|
||||
},
|
||||
{
|
||||
id: 'drivers',
|
||||
title: 'Conductores',
|
||||
icon: User,
|
||||
modelTarget: 'Driver',
|
||||
templateId: 'drivers'
|
||||
},
|
||||
{
|
||||
id: 'trailers',
|
||||
title: 'Trailers y Cajas',
|
||||
icon: Container,
|
||||
modelTarget: 'Trailer',
|
||||
templateId: 'trailers'
|
||||
},
|
||||
];
|
||||
|
||||
export const importacionConfig: CsvUploadItem[] = [
|
||||
// Impo Temp
|
||||
{
|
||||
id: 'imp_temp_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Impo. Temp.',
|
||||
modelTarget: 'invoice_header',
|
||||
templateId: 'imp_temp_header'
|
||||
},
|
||||
{
|
||||
id: 'imp_temp_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Impo. Temp.',
|
||||
modelTarget: 'invoice_details',
|
||||
templateId: 'imp_temp_details'
|
||||
},
|
||||
{
|
||||
id: 'imp_temp_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Impo. Temp.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
// Impo Def
|
||||
{
|
||||
id: 'imp_def_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Impo. Def.',
|
||||
modelTarget: 'invoice_header',
|
||||
templateId: 'imp_def_header'
|
||||
},
|
||||
{
|
||||
id: 'imp_def_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Impo. Def.',
|
||||
modelTarget: 'invoice_details',
|
||||
templateId: 'imp_def_details'
|
||||
},
|
||||
{
|
||||
id: 'imp_def_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Impo. Def.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
// Compras Mex
|
||||
{
|
||||
id: 'comp_mex_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Compras Mex.',
|
||||
modelTarget: 'invoice_header',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'comp_mex_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Compras Mex.',
|
||||
modelTarget: 'invoice_details',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'comp_mex_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Compras Mex.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const exportacionConfig: CsvUploadItem[] = [
|
||||
// Expo Def / Cam. Reg.
|
||||
{
|
||||
id: 'exp_def_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'invoice_header',
|
||||
templateId: 'exp_def_header'
|
||||
},
|
||||
{
|
||||
id: 'exp_def_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'invoice_details',
|
||||
templateId: 'exp_def_details'
|
||||
},
|
||||
{
|
||||
id: 'exp_def_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'exp_def_nodes',
|
||||
title: 'NODES',
|
||||
icon: Briefcase,
|
||||
group: 'Expo. Def./Cam. Reg.',
|
||||
modelTarget: 'Nodes',
|
||||
disabled: true,
|
||||
},
|
||||
// Expo Rep
|
||||
{
|
||||
id: 'exp_rep_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Expo. Rep.',
|
||||
modelTarget: 'InvoiceHeader',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'exp_rep_details',
|
||||
title: 'Partidas',
|
||||
icon: Package,
|
||||
group: 'Expo. Rep.',
|
||||
modelTarget: 'InvoiceSalesDetails',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'exp_rep_series',
|
||||
title: 'Series',
|
||||
icon: Hash,
|
||||
group: 'Expo. Rep.',
|
||||
modelTarget: 'InvoiceSeries',
|
||||
disabled: true,
|
||||
},
|
||||
// Manifiesto
|
||||
{
|
||||
id: 'manifest_header',
|
||||
title: 'Encabezado',
|
||||
icon: FileText,
|
||||
group: 'Manifiesto',
|
||||
modelTarget: 'Manifest',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -41,6 +41,10 @@ export function getAuthTokens(cookies: Cookies) {
|
||||
|
||||
/**
|
||||
* Establece los tokens de autenticación en las cookies
|
||||
*
|
||||
* Política de seguridad:
|
||||
* - access_token → NO HttpOnly (el cliente necesita incluirlo en el header Authorization)
|
||||
* - refresh_token → HttpOnly=true (JS nunca lo lee; el servidor lo maneja via /api-sveltekit/auth/silent-refresh)
|
||||
*/
|
||||
export function setAuthTokens(
|
||||
cookies: Cookies,
|
||||
@@ -49,7 +53,7 @@ export function setAuthTokens(
|
||||
) {
|
||||
cookies.set('access_token', accessToken, {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
httpOnly: false, // El cliente JS necesita leerlo para el header Bearer
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 7 // 7 días
|
||||
@@ -58,7 +62,7 @@ export function setAuthTokens(
|
||||
if (refreshToken) {
|
||||
cookies.set('refresh_token', refreshToken, {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
httpOnly: true, // *** HttpOnly: JS nunca lee el refresh_token ***
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 días
|
||||
|
||||
505
frontend/src/lib/session-manager.ts
Normal file
505
frontend/src/lib/session-manager.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* Gestor de sesión SSO para Keycloak
|
||||
*
|
||||
* Responsabilidades:
|
||||
* - Refresh silencioso del access token SOLO cuando el usuario está activo.
|
||||
* - Detección de actividad del usuario (evita polling innecesario cuando está idle).
|
||||
* - Idle timeout: si el usuario está inactivo, no refrescar → el token y la sesión
|
||||
* expiran en Keycloak de forma natural → siguiente petición 401 → logout.
|
||||
* - Logout automático cuando el refresh falla (sesión SSO terminada por Keycloak,
|
||||
* admin forzado, max session alcanzado, etc.).
|
||||
* - Verificación de sesión SSO usando el iframe silencioso de Keycloak JS.
|
||||
*
|
||||
* Flujo de tokens:
|
||||
* - Access token: en memoria (authStore) + cookie no-HttpOnly (password login)
|
||||
* o en instancia Keycloak JS (SSO flow)
|
||||
* - Refresh token: cookie HttpOnly únicamente (el JS nunca lo toca)
|
||||
* - El refresh se hace server-side via /api-sveltekit/auth/silent-refresh
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import type Keycloak from 'keycloak-js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Eventos DOM personalizados
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Se emite cuando la sesión está próxima a expirar por inactividad */
|
||||
export const SESSION_WARNING_EVENT = 'session:warning';
|
||||
/** Se emite cuando la sesión ha expirado (idle, max-session o refresh fallido) */
|
||||
export const SESSION_EXPIRED_EVENT = 'session:expired';
|
||||
/** Se emite cuando el usuario extiende la sesión desde el diálogo de advertencia */
|
||||
export const SESSION_EXTENDED_EVENT = 'session:extended';
|
||||
/** Se emite después de un refresh silencioso exitoso */
|
||||
export const SESSION_TOKEN_REFRESHED_EVENT = 'session:token-refreshed';
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Tipos
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
export type SessionExpiredReason = 'idle' | 'refresh_failed' | 'keycloak_session_ended' | 'error' | 'manual';
|
||||
|
||||
export interface SessionExpiredDetail {
|
||||
reason: SessionExpiredReason;
|
||||
}
|
||||
|
||||
export interface SessionWarningDetail {
|
||||
remainingMs: number;
|
||||
}
|
||||
|
||||
export interface SessionManagerOptions {
|
||||
/**
|
||||
* Segundos antes de la expiración del token para intentar el refresh.
|
||||
* Default: 60
|
||||
*/
|
||||
refreshBeforeExpirySeconds?: number;
|
||||
|
||||
/**
|
||||
* Tiempo de inactividad (ms) después del cual NO se refresca el token,
|
||||
* permitiendo que la sesión de Keycloak expire de forma natural.
|
||||
* Default: 30 minutos (1_800_000 ms)
|
||||
*/
|
||||
idleTimeoutMs?: number;
|
||||
|
||||
/**
|
||||
* Milisegundos antes del idle timeout para mostrar el diálogo de advertencia.
|
||||
* Default: 5 minutos (300_000 ms)
|
||||
*/
|
||||
warningBeforeIdleMs?: number;
|
||||
|
||||
/**
|
||||
* Intervalo (ms) para verificar silenciosamente la sesión SSO de Keycloak.
|
||||
* Solo se usa cuando hay una instancia de Keycloak JS autenticada.
|
||||
* Default: 5 minutos (300_000 ms). 0 para deshabilitar.
|
||||
*/
|
||||
ssoCheckIntervalMs?: number;
|
||||
|
||||
/** Función para obtener la instancia de Keycloak JS (si usa el SSO flow) */
|
||||
getKeycloakInstance?: () => Keycloak | null;
|
||||
|
||||
/** Callback cuando el token se refresca exitosamente */
|
||||
onTokenRefreshed?: (newToken: string) => void;
|
||||
|
||||
/** Callback cuando la sesión expira */
|
||||
onSessionExpired?: (reason: SessionExpiredReason) => void;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// SessionManager
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
export class SessionManager {
|
||||
private opts: Required<SessionManagerOptions>;
|
||||
|
||||
// Timers
|
||||
private refreshTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
private idleTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
private warningTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
private ssoCheckIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// State
|
||||
private currentToken: string | null = null;
|
||||
private lastActivityAt = Date.now();
|
||||
private warningShown = false;
|
||||
private isRefreshing = false;
|
||||
private destroyed = false;
|
||||
|
||||
// Activity listener cleanups
|
||||
private removeListeners: Array<() => void> = [];
|
||||
|
||||
constructor(options: SessionManagerOptions = {}) {
|
||||
this.opts = {
|
||||
refreshBeforeExpirySeconds: options.refreshBeforeExpirySeconds ?? 60,
|
||||
idleTimeoutMs: options.idleTimeoutMs ?? 30 * 60 * 1000,
|
||||
warningBeforeIdleMs: options.warningBeforeIdleMs ?? 5 * 60 * 1000,
|
||||
ssoCheckIntervalMs: options.ssoCheckIntervalMs ?? 5 * 60 * 1000,
|
||||
getKeycloakInstance: options.getKeycloakInstance ?? (() => null),
|
||||
onTokenRefreshed: options.onTokenRefreshed ?? (() => {}),
|
||||
onSessionExpired: options.onSessionExpired ?? (() => {})
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Inicia el gestor de sesión con el token actual.
|
||||
* Debe llamarse una vez tras la autenticación exitosa.
|
||||
*/
|
||||
start(initialToken: string): void {
|
||||
if (!browser || this.destroyed) return;
|
||||
|
||||
this.currentToken = initialToken;
|
||||
this.lastActivityAt = Date.now();
|
||||
this.warningShown = false;
|
||||
|
||||
this.setupActivityListeners();
|
||||
this.scheduleRefresh(initialToken);
|
||||
this.scheduleIdleTimers();
|
||||
this.startSsoCheckInterval();
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza el token en memoria (llamar después de un refresh exitoso externo).
|
||||
*/
|
||||
updateToken(newToken: string): void {
|
||||
if (this.destroyed) return;
|
||||
this.currentToken = newToken;
|
||||
this.warningShown = false;
|
||||
this.cancelRefreshTimer();
|
||||
this.scheduleRefresh(newToken);
|
||||
this.resetIdleTimers();
|
||||
}
|
||||
|
||||
/**
|
||||
* El usuario hizo clic en "Continuar sesión" en el diálogo de advertencia.
|
||||
* Fuerza un refresh inmediato y reinicia los timers de inactividad.
|
||||
*/
|
||||
extendSession(): void {
|
||||
if (this.destroyed) return;
|
||||
this.recordActivity();
|
||||
if (this.currentToken) {
|
||||
void this.doRefresh('extend');
|
||||
}
|
||||
}
|
||||
|
||||
/** Destruye el gestor y limpia todos los recursos. */
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.cancelRefreshTimer();
|
||||
this.cancelIdleTimers();
|
||||
this.stopSsoCheckInterval();
|
||||
this.teardownActivityListeners();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// Activity tracking
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
private setupActivityListeners(): void {
|
||||
const events: (keyof WindowEventMap)[] = [
|
||||
'mousedown',
|
||||
'mousemove',
|
||||
'keydown',
|
||||
'scroll',
|
||||
'touchstart',
|
||||
'click',
|
||||
'pointerdown'
|
||||
];
|
||||
|
||||
// Limitar actualizaciones de actividad a máximo una por segundo
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handler = () => {
|
||||
if (debounceTimer) return;
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
this.recordActivity();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
events.forEach((event) => {
|
||||
window.addEventListener(event, handler, { passive: true });
|
||||
this.removeListeners.push(() => window.removeEventListener(event, handler));
|
||||
});
|
||||
|
||||
// Al volver a la pestaña, registrar actividad y verificar si hace falta
|
||||
// un refresh inmediato (el tiempo puede haber pasado con la pestaña en segundo plano)
|
||||
const visibilityHandler = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
this.recordActivity();
|
||||
void this.refreshIfExpiringSoon();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', visibilityHandler);
|
||||
this.removeListeners.push(() =>
|
||||
document.removeEventListener('visibilitychange', visibilityHandler)
|
||||
);
|
||||
}
|
||||
|
||||
private teardownActivityListeners(): void {
|
||||
this.removeListeners.forEach((fn) => fn());
|
||||
this.removeListeners = [];
|
||||
}
|
||||
|
||||
private recordActivity(): void {
|
||||
const wasIdle = this.isIdle();
|
||||
this.lastActivityAt = Date.now();
|
||||
|
||||
if (this.warningShown) {
|
||||
// El usuario volvió activo → descartar advertencia
|
||||
this.warningShown = false;
|
||||
this.resetIdleTimers();
|
||||
window.dispatchEvent(new CustomEvent(SESSION_EXTENDED_EVENT));
|
||||
} else if (wasIdle) {
|
||||
// Volvemos de idle → reiniciar timers
|
||||
this.resetIdleTimers();
|
||||
void this.refreshIfExpiringSoon();
|
||||
}
|
||||
}
|
||||
|
||||
private isIdle(): boolean {
|
||||
return Date.now() - this.lastActivityAt > this.opts.idleTimeoutMs;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// Token refresh scheduling
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
private parseExpiry(token: string): number | null {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return typeof payload.exp === 'number' ? payload.exp * 1000 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRefresh(token: string): void {
|
||||
const expiry = this.parseExpiry(token);
|
||||
if (!expiry) return;
|
||||
|
||||
const msUntilRefresh = expiry - Date.now() - this.opts.refreshBeforeExpirySeconds * 1000;
|
||||
|
||||
if (msUntilRefresh <= 0) {
|
||||
void this.doRefresh('scheduled');
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshTimerId = setTimeout(() => {
|
||||
if (!this.destroyed) void this.doRefresh('scheduled');
|
||||
}, msUntilRefresh);
|
||||
}
|
||||
|
||||
private cancelRefreshTimer(): void {
|
||||
if (this.refreshTimerId !== null) {
|
||||
clearTimeout(this.refreshTimerId);
|
||||
this.refreshTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresca el token si le quedan menos de `refreshBeforeExpirySeconds` */
|
||||
private async refreshIfExpiringSoon(): Promise<void> {
|
||||
if (!this.currentToken) return;
|
||||
const expiry = this.parseExpiry(this.currentToken);
|
||||
if (!expiry) return;
|
||||
if (expiry - Date.now() < this.opts.refreshBeforeExpirySeconds * 1000) {
|
||||
await this.doRefresh('on-demand');
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// Idle session timers
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
private scheduleIdleTimers(): void {
|
||||
this.cancelIdleTimers();
|
||||
const now = Date.now();
|
||||
const idleAt = this.lastActivityAt + this.opts.idleTimeoutMs;
|
||||
const warnAt = idleAt - this.opts.warningBeforeIdleMs;
|
||||
|
||||
const msUntilWarn = warnAt - now;
|
||||
const msUntilIdle = idleAt - now;
|
||||
|
||||
if (msUntilWarn > 0) {
|
||||
this.warningTimerId = setTimeout(() => {
|
||||
if (!this.destroyed && !this.warningShown && this.isIdle() === false) {
|
||||
this.showWarning(this.opts.warningBeforeIdleMs);
|
||||
}
|
||||
}, msUntilWarn);
|
||||
}
|
||||
|
||||
if (msUntilIdle > 0) {
|
||||
this.idleTimerId = setTimeout(() => {
|
||||
if (!this.destroyed && this.isIdle()) {
|
||||
this.handleIdleExpiry();
|
||||
}
|
||||
}, msUntilIdle);
|
||||
}
|
||||
}
|
||||
|
||||
private cancelIdleTimers(): void {
|
||||
if (this.warningTimerId !== null) {
|
||||
clearTimeout(this.warningTimerId);
|
||||
this.warningTimerId = null;
|
||||
}
|
||||
if (this.idleTimerId !== null) {
|
||||
clearTimeout(this.idleTimerId);
|
||||
this.idleTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private resetIdleTimers(): void {
|
||||
this.scheduleIdleTimers();
|
||||
}
|
||||
|
||||
private showWarning(remainingMs: number): void {
|
||||
this.warningShown = true;
|
||||
const detail: SessionWarningDetail = { remainingMs };
|
||||
window.dispatchEvent(new CustomEvent(SESSION_WARNING_EVENT, { detail }));
|
||||
}
|
||||
|
||||
private handleIdleExpiry(): void {
|
||||
const detail: SessionExpiredDetail = { reason: 'idle' };
|
||||
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail }));
|
||||
this.opts.onSessionExpired('idle');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// Periodic Keycloak SSO session check (iframe)
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
private startSsoCheckInterval(): void {
|
||||
if (this.opts.ssoCheckIntervalMs <= 0) return;
|
||||
|
||||
this.ssoCheckIntervalId = setInterval(() => {
|
||||
if (!this.destroyed) void this.checkKeycloakSsoSession();
|
||||
}, this.opts.ssoCheckIntervalMs);
|
||||
}
|
||||
|
||||
private stopSsoCheckInterval(): void {
|
||||
if (this.ssoCheckIntervalId !== null) {
|
||||
clearInterval(this.ssoCheckIntervalId);
|
||||
this.ssoCheckIntervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprueba silenciosamente si la sesión SSO de Keycloak sigue activa.
|
||||
* Si el check falla (sesión terminada remotamente) → logout.
|
||||
*/
|
||||
private async checkKeycloakSsoSession(): Promise<void> {
|
||||
const kc = this.opts.getKeycloakInstance();
|
||||
if (!kc?.authenticated) return; // Solo aplica al flow SSO con Keycloak JS
|
||||
|
||||
try {
|
||||
// updateToken(0) fuerza a Keycloak JS a intentar refrescar via SSO
|
||||
// Si la sesión SSO de Keycloak ha sido terminada, lanza un error
|
||||
await kc.updateToken(0);
|
||||
} catch {
|
||||
console.warn('[SessionManager] Keycloak SSO session ended remotely');
|
||||
const detail: SessionExpiredDetail = { reason: 'keycloak_session_ended' };
|
||||
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail }));
|
||||
this.opts.onSessionExpired('keycloak_session_ended');
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// Token refresh execution
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
private async doRefresh(reason: string): Promise<void> {
|
||||
if (this.destroyed || this.isRefreshing) return;
|
||||
|
||||
// No refrescar automáticamente si el usuario está idle
|
||||
// (excepto si es un refresh forzado por "extender sesión")
|
||||
if (reason === 'scheduled' && this.isIdle()) {
|
||||
console.info('[SessionManager] Omitiendo refresh — usuario inactivo');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
const kc = this.opts.getKeycloakInstance();
|
||||
let newToken: string | null = null;
|
||||
|
||||
if (kc?.authenticated) {
|
||||
// ── Keycloak JS flow ──────────────────────────────────────────
|
||||
// updateToken intenta un silent refresh via iframe con la cookie
|
||||
// de sesión SSO de Keycloak.
|
||||
// Si el SSO session ha expirado, esto lanzará un error.
|
||||
const minValidity = this.opts.refreshBeforeExpirySeconds + 10;
|
||||
await kc.updateToken(minValidity);
|
||||
newToken = kc.token ?? null;
|
||||
} else {
|
||||
// ── Password login flow ───────────────────────────────────────
|
||||
// Usar el endpoint server-side de SvelteKit que lee el refresh_token
|
||||
// desde la cookie HttpOnly (el JS nunca ve el refresh_token).
|
||||
newToken = await this.silentRefreshViaCookie();
|
||||
}
|
||||
|
||||
if (newToken) {
|
||||
this.currentToken = newToken;
|
||||
this.cancelRefreshTimer();
|
||||
this.scheduleRefresh(newToken);
|
||||
this.opts.onTokenRefreshed(newToken);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SESSION_TOKEN_REFRESHED_EVENT, { detail: { token: newToken } })
|
||||
);
|
||||
} else {
|
||||
this.handleRefreshFailure();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SessionManager] Error durante refresh:', err);
|
||||
this.handleRefreshFailure();
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleRefreshFailure(): void {
|
||||
console.warn('[SessionManager] Refresh fallido — la sesión SSO probablemente expiró');
|
||||
const detail: SessionExpiredDetail = { reason: 'refresh_failed' };
|
||||
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, { detail }));
|
||||
this.opts.onSessionExpired('refresh_failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Llama al endpoint server-side de SvelteKit para realizar el refresh
|
||||
* usando la cookie HttpOnly del refresh_token.
|
||||
*
|
||||
* El servidor lee la cookie, llama a Keycloak, obtiene los nuevos tokens,
|
||||
* actualiza las cookies HttpOnly y devuelve el nuevo access_token al cliente.
|
||||
* El refresh_token NUNCA toca el código JavaScript del cliente.
|
||||
*/
|
||||
private async silentRefreshViaCookie(): Promise<string | null> {
|
||||
try {
|
||||
const resp = await fetch('/api-sveltekit/auth/silent-refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include', // Envía todas las cookies HttpOnly
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!resp.ok) return null;
|
||||
|
||||
const data = await resp.json();
|
||||
return (data as { access_token?: string }).access_token ?? null;
|
||||
} catch (err) {
|
||||
console.error('[SessionManager] Error en silentRefreshViaCookie:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Singleton helpers
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
let _instance: SessionManager | null = null;
|
||||
|
||||
/** Obtiene la instancia singleton del SessionManager */
|
||||
export function getSessionManager(): SessionManager | null {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea (o recrea) el SessionManager singleton.
|
||||
* Destruye la instancia anterior si existe.
|
||||
*/
|
||||
export function createSessionManager(options?: SessionManagerOptions): SessionManager {
|
||||
if (_instance) {
|
||||
_instance.destroy();
|
||||
}
|
||||
_instance = new SessionManager(options);
|
||||
return _instance;
|
||||
}
|
||||
|
||||
/** Destruye el SessionManager singleton y limpia todos los recursos */
|
||||
export function destroySessionManager(): void {
|
||||
if (_instance) {
|
||||
_instance.destroy();
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Endpoint server-side para el refresh silencioso del access token.
|
||||
*
|
||||
* Flujo de seguridad:
|
||||
* 1. El cliente llama a POST /api-sveltekit/auth/silent-refresh con credentials:'include'
|
||||
* (las cookies HttpOnly se envían automáticamente, sin que JS las lea).
|
||||
* 2. Este servidor lee el refresh_token de la cookie HttpOnly.
|
||||
* 3. Llama al backend FastAPI /v1/auth/refresh con el refresh_token.
|
||||
* 4. Si es exitoso, actualiza las cookies HttpOnly con los nuevos tokens.
|
||||
* 5. Retorna solo el access_token al cliente (el refresh_token permanece en HttpOnly).
|
||||
*
|
||||
* De este modo el refresh_token NUNCA toca el código JavaScript del cliente.
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { getServerApiUrl, setAuthTokens } from '$lib/server/api';
|
||||
|
||||
export const POST = async ({ cookies, fetch }: RequestEvent) => {
|
||||
const refreshToken = cookies.get('refresh_token');
|
||||
|
||||
if (!refreshToken) {
|
||||
return json({ error: 'No refresh token available' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
|
||||
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// El refresh token expiró o fue invalidado por Keycloak (sesión terminada).
|
||||
// Limpiar las cookies para que el servidor redirigir al login en la siguiente carga.
|
||||
cookies.delete('refresh_token', { path: '/' });
|
||||
cookies.delete('access_token', { path: '/' });
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
|
||||
const status = response.status === 401 ? 401 : 400;
|
||||
return json({ error: 'Refresh token expired or invalid' }, { status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
|
||||
// Actualizar las cookies HttpOnly con los nuevos tokens
|
||||
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
||||
|
||||
// Devolver solo el access_token al cliente
|
||||
return json({ access_token: data.access_token });
|
||||
} catch (error) {
|
||||
console.error('[silent-refresh] Error inesperado:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
||||
// Obtener el código y state de los query params
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
@@ -56,13 +56,14 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
// Establecer las cookies en el servidor (esto es lo importante)
|
||||
// Las cookies deben ser HttpOnly y Secure en producción
|
||||
// Establecer las cookies en el servidor
|
||||
// access_token → NO HttpOnly (el cliente JS lo usa para el header Authorization)
|
||||
// refresh_token → HttpOnly (el JS nunca lo lee; el servidor lo gestiona)
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
cookies.set('access_token', tokens.access_token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
httpOnly: false, // El cliente necesita leerlo para Bearer
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7 // 7 días
|
||||
@@ -71,7 +72,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
if (tokens.refresh_token) {
|
||||
cookies.set('refresh_token', tokens.refresh_token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
httpOnly: true, // *** HttpOnly: nunca expuesto a JS ***
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 días
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { setContext, onMount } from 'svelte';
|
||||
import { setContext, onMount, onDestroy } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { LayoutData } from './$types';
|
||||
import AppSidebar from '$lib/components/sidebar/app-sidebar.svelte';
|
||||
@@ -8,29 +8,78 @@
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
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 {
|
||||
createSessionManager,
|
||||
destroySessionManager,
|
||||
SESSION_EXPIRED_EVENT
|
||||
} from '$lib/session-manager';
|
||||
import type { SessionExpiredDetail } from '$lib/session-manager';
|
||||
import { authStore } from '$lib/auth';
|
||||
import { logout, getKeycloakInstance } from '$lib/auth';
|
||||
|
||||
let { data, children }: { data: LayoutData; children: any } = $props();
|
||||
|
||||
// Hacer disponible el usuario en el contexto para los componentes hijos
|
||||
setContext('user', data.user);
|
||||
|
||||
// Inicializar el store con las compañías pre-cargadas desde el servidor
|
||||
// ── Manejar expiración de sesión ────────────────────────────────────────
|
||||
function handleSessionExpired(e: Event) {
|
||||
const { reason } = (e as CustomEvent<SessionExpiredDetail>).detail;
|
||||
console.info(`[Dashboard] Sesión expirada (motivo: ${reason}) — cerrando sesión`);
|
||||
void logout();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// ── Inicializar el token en el authStore desde los datos del servidor ──
|
||||
// El servidor valida la cookie y pasa el access_token a través de data.user.token.
|
||||
// Lo almacenamos en memoria (authStore) sin tocar localStorage.
|
||||
if (data.user?.token) {
|
||||
authStore.setToken(data.user.token);
|
||||
authStore.setAuthenticated(true);
|
||||
}
|
||||
|
||||
// ── Inicializar el SessionManager ─────────────────────────────────────
|
||||
if (data.user?.token) {
|
||||
const mgr = createSessionManager({
|
||||
refreshBeforeExpirySeconds: 60, // Refrescar 60s antes de que expire
|
||||
idleTimeoutMs: 30 * 60 * 1000, // Idle timeout: 30 minutos
|
||||
warningBeforeIdleMs: 5 * 60 * 1000, // Advertencia: 5 min antes del idle
|
||||
ssoCheckIntervalMs: 5 * 60 * 1000, // Verificar sesión SSO cada 5 min
|
||||
getKeycloakInstance,
|
||||
onTokenRefreshed: (newToken) => {
|
||||
authStore.setToken(newToken);
|
||||
},
|
||||
onSessionExpired: (reason) => {
|
||||
void logout();
|
||||
}
|
||||
});
|
||||
|
||||
mgr.start(data.user.token);
|
||||
}
|
||||
|
||||
// ── Escuchar evento global de expiración de sesión ────────────────────
|
||||
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
|
||||
|
||||
// ── Inicializar compañías ─────────────────────────────────────────────
|
||||
if (data.companies) {
|
||||
companyStore.initialize(data.companies);
|
||||
}
|
||||
|
||||
// Escuchar cambios de compañía y recargar datos
|
||||
const handleCompanyChange = () => {
|
||||
invalidateAll();
|
||||
};
|
||||
// ── Escuchar cambios de compañía y recargar datos ─────────────────────
|
||||
const handleCompanyChange = () => invalidateAll();
|
||||
window.addEventListener('companyChanged', handleCompanyChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('companyChanged', handleCompanyChange);
|
||||
window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
destroySessionManager();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sidebar.Provider>
|
||||
@@ -67,3 +116,6 @@
|
||||
{#if !page.url.pathname.includes('/dashboard/invoices') && !page.url.pathname.includes('/dashboard/pedimentos')}
|
||||
<ExchangeRateGuard overlayClass="bg-black/5" />
|
||||
{/if}
|
||||
|
||||
<!-- Diálogo de advertencia de sesión por inactividad -->
|
||||
<SessionTimeoutWarning />
|
||||
|
||||
@@ -1,209 +1,549 @@
|
||||
<script lang="ts">
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import UploadLauncherGrid from '$lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte';
|
||||
import ConfigFooter from '$lib/components/dashboard/csv-upload/ConfigFooter.svelte';
|
||||
import ProcessingResultModal from '$lib/components/dashboard/csv-upload/ProcessingResultModal.svelte';
|
||||
import {
|
||||
catalogosConfig,
|
||||
transportesConfig,
|
||||
importacionConfig,
|
||||
exportacionConfig,
|
||||
tabSettings,
|
||||
type CsvUploadItem
|
||||
} from '$lib/config/csv-upload';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// We no longer need modal state
|
||||
let activeTab = $state('catalogos');
|
||||
|
||||
let isUploading = $state(false);
|
||||
let currentJobId = $state<string | null>(null);
|
||||
let activeModelTarget = $state<string | null>(null);
|
||||
let scanResults = $state<any>(null);
|
||||
let commitResults = $state<any>(null);
|
||||
let showResultModal = $state(false);
|
||||
|
||||
// Initialize settings for all tabs upfront to avoid reactivity loops
|
||||
let allSettings = $state<Record<string, any>>(() => {
|
||||
const initial: Record<string, any> = {};
|
||||
for (const tab in tabSettings) {
|
||||
initial[tab] = {};
|
||||
tabSettings[tab].forEach((f) => {
|
||||
initial[tab][f.name] = f.defaultValue;
|
||||
});
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
async function handleUpload(file: File, config: CsvUploadItem) {
|
||||
isUploading = true;
|
||||
activeModelTarget = config.modelTarget || null;
|
||||
scanResults = null;
|
||||
const currentSettings = allSettings[activeTab] || {};
|
||||
const companyId = companyStore.activeCompany?.id || 1;
|
||||
const opType = activeTab === 'exportacion' ? 'exp' : 'imp';
|
||||
|
||||
const res = await api.imports.upload(
|
||||
file,
|
||||
config.modelTarget || '',
|
||||
currentSettings,
|
||||
companyId,
|
||||
opType
|
||||
);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error('Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
if (!currentJobId) return;
|
||||
|
||||
const res = await api.imports.status(currentJobId);
|
||||
if (res.data?.status === 'waiting_confirmation') {
|
||||
scanResults = res.data;
|
||||
showResultModal = true;
|
||||
toast.success('Escaneo completado. Revisa los resultados.');
|
||||
isUploading = false;
|
||||
} else if (res.data?.status === 'failed') {
|
||||
toast.error('Error en el procesamiento: ' + (res.data.error || 'Error desconocido'));
|
||||
isUploading = false;
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
} else if (res.data?.status === 'warning') {
|
||||
// Caso cuando no se insertaron registros pero hay información de rechazo
|
||||
commitResults = res.data;
|
||||
showResultModal = true;
|
||||
const inserted = res.data?.inserted || 0;
|
||||
const skippedInvalid = res.data?.skipped_invalid || 0;
|
||||
const skippedFk = res.data?.skipped_missing_fk || 0;
|
||||
const totalSkipped = skippedInvalid + skippedFk;
|
||||
|
||||
if (inserted === 0) {
|
||||
toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`);
|
||||
} else {
|
||||
toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`);
|
||||
}
|
||||
isUploading = false;
|
||||
} else if (res.data?.status === 'finished') {
|
||||
commitResults = res.data;
|
||||
showResultModal = true;
|
||||
const inserted = res.data?.inserted || 0;
|
||||
const skippedInvalid = res.data?.skipped_invalid || 0;
|
||||
const skippedFk = res.data?.skipped_missing_fk || 0;
|
||||
const skippedDetails = res.data?.skipped_details || [];
|
||||
|
||||
if (inserted > 0) {
|
||||
toast.success(`Importación completada: ${inserted} registros insertados`);
|
||||
if (skippedInvalid > 0 || skippedFk > 0) {
|
||||
const totalSkipped = skippedInvalid + skippedFk;
|
||||
toast.warning(`${totalSkipped} registros fueron rechazados`);
|
||||
}
|
||||
} else {
|
||||
toast.error('No se insertaron registros. Revisa los errores a continuación.');
|
||||
}
|
||||
isUploading = false;
|
||||
} else {
|
||||
// Continue polling
|
||||
setTimeout(pollStatus, 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-[calc(100vh-4rem)] -m-4 overflow-hidden">
|
||||
<!-- Scrollable Content Area -->
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-lg font-semibold md:text-2xl">Importación Masiva de Datos (CSV)</h1>
|
||||
</div>
|
||||
|
||||
<Tabs.Root bind:value={activeTab} class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-2 md:grid-cols-4 lg:w-auto">
|
||||
<Tabs.Trigger value="catalogos">Catálogos</Tabs.Trigger>
|
||||
<Tabs.Trigger value="transportes">Transportes</Tabs.Trigger>
|
||||
<Tabs.Trigger value="importacion">Importación</Tabs.Trigger>
|
||||
<Tabs.Trigger value="exportacion">Exportación</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<div class="mt-6">
|
||||
<Tabs.Content value="catalogos" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Catálogos Generales</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={catalogosConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="transportes" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Logística y Transporte</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={transportesConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="importacion" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Operaciones de Importación</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={importacionConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="exportacion" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Operaciones de Exportación</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={exportacionConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
<div class="h-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer Area -->
|
||||
{#if allSettings[activeTab]}
|
||||
<div class="flex-none z-20">
|
||||
<ConfigFooter {activeTab} bind:settings={allSettings[activeTab]} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ProcessingResultModal
|
||||
bind:open={showResultModal}
|
||||
{scanResults}
|
||||
{commitResults}
|
||||
{isUploading}
|
||||
onConfirm={async () => {
|
||||
if (currentJobId && activeModelTarget) {
|
||||
try {
|
||||
isUploading = true;
|
||||
const res = await api.imports.commit(currentJobId, activeModelTarget);
|
||||
if (res.data?.commit_job_id) {
|
||||
currentJobId = res.data.commit_job_id;
|
||||
pollStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al iniciar la importación');
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
}}
|
||||
onClose={() => {
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
}}
|
||||
/>
|
||||
<script lang="ts">
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import UploadLauncherGrid from '$lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte';
|
||||
import ConfigFooter from '$lib/components/dashboard/csv-upload/ConfigFooter.svelte';
|
||||
import ProcessingResultModal from '$lib/components/dashboard/csv-upload/ProcessingResultModal.svelte';
|
||||
import {
|
||||
catalogosConfig,
|
||||
transportesConfig,
|
||||
importacionConfig,
|
||||
exportacionConfig,
|
||||
tabSettings,
|
||||
type CsvUploadItem
|
||||
} from '$lib/config/csv-upload';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
// We no longer need modal state
|
||||
let activeTab = $state('catalogos');
|
||||
|
||||
let isUploading = $state(false);
|
||||
let currentJobId = $state<string | null>(null);
|
||||
let activeModelTarget = $state<string | null>(null);
|
||||
let scanResults = $state<any>(null);
|
||||
let commitResults = $state<any>(null);
|
||||
let showResultModal = $state(false);
|
||||
// Cuando es true, usamos API de importación de Agentes Aduanales (customs_brokers/imports)
|
||||
let useCustomsBrokerImport = $state(false);
|
||||
// Cuando es true, usamos API de importación de Clientes y Proveedores (clients_and_providers/imports)
|
||||
let useClientProviderImport = $state(false);
|
||||
// Cuando es true, usamos API de importación de Tipos de Cambio (exchange_rate/imports)
|
||||
let useExchangeRateImport = $state(false);
|
||||
// Cuando es true, usamos API de importación de Fracción Americana (us_tariff_fractions/imports)
|
||||
let useAmericanFractionImport = $state(false);
|
||||
// Cuando es true, usamos API de importación de Pedimentos (pedimentos/imports)
|
||||
let usePedimentosImport = $state(false);
|
||||
// Cuando es true, usamos API de importación de Clases de Materiales (classes/imports)
|
||||
let useMaterialClassesImport = $state(false);
|
||||
// Cuando es true, usamos API de importación de Vehículos / Transportes (vehicles/imports)
|
||||
let useVehicleImport = $state(false);
|
||||
// Cuando es true, usamos API de importación de Conductores (drivers/imports)
|
||||
let useDriverImport = $state(false);
|
||||
// Cuando es true, usamos API de importación de Trailers y Cajas (trailers/imports)
|
||||
let useTrailerImport = $state(false);
|
||||
// Cuando es true, usamos API de importación de Transportistas (transporters/imports)
|
||||
let useTransporterImport = $state(false);
|
||||
// Cuando es true, usamos API de importación de Números de parte (parts/imports)
|
||||
let usePartNumbersImport = $state(false);
|
||||
// Cuando es true, usamos API de importación de BOMs (boms/imports)
|
||||
let useBomImport = $state(false);
|
||||
|
||||
// Initialize settings for all tabs upfront to avoid reactivity loops
|
||||
let allSettings = $state<Record<string, any>>(() => {
|
||||
const initial: Record<string, any> = {};
|
||||
for (const tab in tabSettings) {
|
||||
initial[tab] = {};
|
||||
tabSettings[tab].forEach((f) => {
|
||||
initial[tab][f.name] = f.defaultValue;
|
||||
});
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
async function handleUpload(file: File, config: CsvUploadItem) {
|
||||
console.log('handleUpload started', { file, config });
|
||||
isUploading = true;
|
||||
activeModelTarget = config.modelTarget || null;
|
||||
scanResults = null;
|
||||
useCustomsBrokerImport = config.id === 'customs_brokers';
|
||||
useClientProviderImport = config.id === 'clients_providers';
|
||||
useExchangeRateImport = config.id === 'exchange_rates';
|
||||
useAmericanFractionImport = config.id === 'american_fractions';
|
||||
usePedimentosImport = config.id === 'pedimentos';
|
||||
useMaterialClassesImport = config.id === 'material_classes';
|
||||
useVehicleImport = config.id === 'transports';
|
||||
useDriverImport = config.id === 'drivers';
|
||||
useTrailerImport = config.id === 'trailers';
|
||||
useTransporterImport = config.id === 'transporters';
|
||||
usePartNumbersImport = config.id === 'part_numbers';
|
||||
useBomImport = config.id === 'boms';
|
||||
|
||||
const companyId = companyStore.activeCompany?.id || 1;
|
||||
|
||||
if (useCustomsBrokerImport) {
|
||||
try {
|
||||
const res = await api.customsBrokerImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useClientProviderImport) {
|
||||
try {
|
||||
const res = await api.clientProviderImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useExchangeRateImport) {
|
||||
try {
|
||||
const res = await api.exchangeRateImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useAmericanFractionImport) {
|
||||
try {
|
||||
const res = await api.americanFractionImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (usePedimentosImport) {
|
||||
try {
|
||||
const res = await api.pedimentosImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useMaterialClassesImport) {
|
||||
try {
|
||||
const res = await api.materialClassImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useVehicleImport) {
|
||||
try {
|
||||
const res = await api.vehicleImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useDriverImport) {
|
||||
try {
|
||||
const res = await api.driverImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useTrailerImport) {
|
||||
try {
|
||||
const res = await api.trailerImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useTransporterImport) {
|
||||
try {
|
||||
const res = await api.transporterImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (usePartNumbersImport) {
|
||||
try {
|
||||
const res = await api.partNumberImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (useBomImport) {
|
||||
try {
|
||||
const res = await api.bomImports.upload(file, companyId);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSettings = allSettings[activeTab] || {};
|
||||
const footerConfig = { ...currentSettings };
|
||||
if (activeTab === 'importacion') {
|
||||
footerConfig.invoice_type = config.id?.startsWith('imp_def_') ? 'DEF' : 'TEM';
|
||||
}
|
||||
const opType = activeTab === 'exportacion' ? 'exp' : 'imp';
|
||||
|
||||
try {
|
||||
const res = await api.imports.upload(
|
||||
file,
|
||||
config.modelTarget || '',
|
||||
footerConfig,
|
||||
companyId,
|
||||
opType,
|
||||
config.id
|
||||
);
|
||||
if (res.data?.job_id) {
|
||||
currentJobId = res.data.job_id;
|
||||
pollStatus();
|
||||
} else {
|
||||
toast.error(res.error || 'Error al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload exception', e);
|
||||
toast.error('Error inesperado al subir el archivo');
|
||||
isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
if (!currentJobId) return;
|
||||
|
||||
try {
|
||||
const res = useCustomsBrokerImport
|
||||
? await api.customsBrokerImports.status(currentJobId)
|
||||
: useClientProviderImport
|
||||
? await api.clientProviderImports.status(currentJobId)
|
||||
: useExchangeRateImport
|
||||
? await api.exchangeRateImports.status(currentJobId)
|
||||
: useAmericanFractionImport
|
||||
? await api.americanFractionImports.status(currentJobId)
|
||||
: usePedimentosImport
|
||||
? await api.pedimentosImports.status(currentJobId)
|
||||
: useMaterialClassesImport
|
||||
? await api.materialClassImports.status(currentJobId)
|
||||
: useVehicleImport
|
||||
? await api.vehicleImports.status(currentJobId)
|
||||
: useDriverImport
|
||||
? await api.driverImports.status(currentJobId)
|
||||
: useTrailerImport
|
||||
? await api.trailerImports.status(currentJobId)
|
||||
: useTransporterImport
|
||||
? await api.transporterImports.status(currentJobId)
|
||||
: usePartNumbersImport
|
||||
? await api.partNumberImports.status(currentJobId)
|
||||
: useBomImport
|
||||
? await api.bomImports.status(currentJobId)
|
||||
: await api.imports.status(currentJobId);
|
||||
console.log('Poll response', res);
|
||||
if (res.error && !res.data) {
|
||||
toast.error(res.error || 'Error al consultar el estado');
|
||||
isUploading = false;
|
||||
currentJobId = null;
|
||||
return;
|
||||
}
|
||||
if (res.data?.status === 'waiting_confirmation') {
|
||||
scanResults = res.data;
|
||||
showResultModal = true;
|
||||
toast.success('Escaneo completado. Revisa los resultados.');
|
||||
isUploading = false;
|
||||
} else if (res.data?.status === 'failed' || res.data?.status === 'FAILURE') {
|
||||
const errRaw = res.data.error;
|
||||
const errText =
|
||||
typeof errRaw === 'string'
|
||||
? errRaw.includes('finished') && errRaw.includes('inserted')
|
||||
? 'La importación pudo completarse. Revisa el listado de registros.'
|
||||
: errRaw
|
||||
: (errRaw?.message ?? 'Error desconocido');
|
||||
toast.error('Error en el procesamiento: ' + errText);
|
||||
isUploading = false;
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
} else if (res.data?.status === 'warning') {
|
||||
// Caso cuando no se insertaron registros pero hay información de rechazo
|
||||
commitResults = res.data;
|
||||
showResultModal = true;
|
||||
const inserted = res.data?.inserted || 0;
|
||||
const skippedInvalid = res.data?.skipped_invalid || 0;
|
||||
const skippedFk = res.data?.skipped_missing_fk || 0;
|
||||
const skippedDup = res.data?.skipped_duplicate || 0;
|
||||
const totalSkipped = skippedInvalid + skippedFk + skippedDup;
|
||||
|
||||
if (inserted === 0) {
|
||||
toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`);
|
||||
} else {
|
||||
toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`);
|
||||
}
|
||||
isUploading = false;
|
||||
} else if (res.data?.status === 'finished') {
|
||||
commitResults = res.data;
|
||||
showResultModal = true;
|
||||
const inserted = res.data?.inserted || 0;
|
||||
const skippedInvalid = res.data?.skipped_invalid || 0;
|
||||
const skippedFk = res.data?.skipped_missing_fk || 0;
|
||||
const skippedDup = res.data?.skipped_duplicate || 0;
|
||||
const skippedDetails = res.data?.skipped_details || [];
|
||||
|
||||
if (inserted > 0) {
|
||||
toast.success(`Importación completada: ${inserted} registros insertados`);
|
||||
if (skippedInvalid > 0 || skippedFk > 0 || skippedDup > 0) {
|
||||
const totalSkipped = skippedInvalid + skippedFk + skippedDup;
|
||||
toast.warning(`${totalSkipped} registros fueron rechazados`);
|
||||
}
|
||||
} else {
|
||||
toast.error('No se insertaron registros. Revisa los errores a continuación.');
|
||||
}
|
||||
isUploading = false;
|
||||
} else {
|
||||
// Continue polling
|
||||
console.log('Status not final, polling again in 2s...', res.data?.status);
|
||||
setTimeout(pollStatus, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Poll exception', e);
|
||||
// Retry on network error? Or fail?
|
||||
// For now, let's keep retrying a few times or hard fail.
|
||||
// Let's just log and retry.
|
||||
setTimeout(pollStatus, 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-[calc(100vh-4rem)] -m-4 overflow-hidden">
|
||||
<!-- Scrollable Content Area -->
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-lg font-semibold md:text-2xl">Importación Masiva de Datos (CSV)</h1>
|
||||
</div>
|
||||
|
||||
<Tabs.Root bind:value={activeTab} class="w-full">
|
||||
<Tabs.List class="grid w-full grid-cols-2 md:grid-cols-4 lg:w-auto">
|
||||
<Tabs.Trigger value="catalogos">Catálogos</Tabs.Trigger>
|
||||
<Tabs.Trigger value="transportes">Transportes</Tabs.Trigger>
|
||||
<Tabs.Trigger value="importacion">Importación</Tabs.Trigger>
|
||||
<Tabs.Trigger value="exportacion">Exportación</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<div class="mt-6">
|
||||
<Tabs.Content value="catalogos" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Catálogos Generales</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={catalogosConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="transportes" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Logística y Transporte</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={transportesConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="importacion" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Operaciones de Importación</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={importacionConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="exportacion" class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-medium tracking-tight">Operaciones de Exportación</h2>
|
||||
</div>
|
||||
<UploadLauncherGrid items={exportacionConfig} onUpload={handleUpload} />
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
<div class="h-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed Footer Area -->
|
||||
{#if allSettings[activeTab]}
|
||||
<div class="flex-none z-20">
|
||||
<ConfigFooter {activeTab} bind:settings={allSettings[activeTab]} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if scanResults || commitResults}
|
||||
<ProcessingResultModal
|
||||
bind:open={showResultModal}
|
||||
{scanResults}
|
||||
{commitResults}
|
||||
{isUploading}
|
||||
onConfirm={async () => {
|
||||
if (!currentJobId) return;
|
||||
try {
|
||||
isUploading = true;
|
||||
const res = useCustomsBrokerImport
|
||||
? await api.customsBrokerImports.commit(currentJobId)
|
||||
: useClientProviderImport
|
||||
? await api.clientProviderImports.commit(currentJobId)
|
||||
: useExchangeRateImport
|
||||
? await api.exchangeRateImports.commit(currentJobId)
|
||||
: useAmericanFractionImport
|
||||
? await api.americanFractionImports.commit(currentJobId)
|
||||
: usePedimentosImport
|
||||
? await api.pedimentosImports.commit(currentJobId)
|
||||
: useMaterialClassesImport
|
||||
? await api.materialClassImports.commit(currentJobId)
|
||||
: useVehicleImport
|
||||
? await api.vehicleImports.commit(currentJobId)
|
||||
: useDriverImport
|
||||
? await api.driverImports.commit(currentJobId)
|
||||
: useTrailerImport
|
||||
? await api.trailerImports.commit(currentJobId)
|
||||
: useTransporterImport
|
||||
? await api.transporterImports.commit(currentJobId)
|
||||
: usePartNumbersImport
|
||||
? await api.partNumberImports.commit(currentJobId)
|
||||
: useBomImport
|
||||
? await api.bomImports.commit(currentJobId)
|
||||
: await api.imports.commit(currentJobId, activeModelTarget || '');
|
||||
if (res.data?.commit_job_id) {
|
||||
currentJobId = res.data.commit_job_id;
|
||||
pollStatus();
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al iniciar la importación');
|
||||
isUploading = false;
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
}}
|
||||
onClose={() => {
|
||||
currentJobId = null;
|
||||
scanResults = null;
|
||||
commitResults = null;
|
||||
showResultModal = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -199,7 +199,9 @@
|
||||
}
|
||||
|
||||
function handleStateSelect(state: any) {
|
||||
formData.state = state.m3_key || state.mex_key || state.ame_key;
|
||||
// Mostrar en Estado la clave del estado (mex_key/ame_key), no el país (m3_key)
|
||||
formData.state = state.mex_key || state.ame_key || state.m3_key;
|
||||
if (state.m3_key) formData.country = state.m3_key;
|
||||
}
|
||||
|
||||
// --- 4. GUARDADO ---
|
||||
@@ -538,6 +540,7 @@
|
||||
placeholder="32000"
|
||||
disabled={loading}
|
||||
class="h-10"
|
||||
oninput={(e) => { formData.postal_code = e.currentTarget.value.replace(/[^a-zA-Z0-9]/g, ''); }}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2 md:col-span-2">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { getAuthTokens, getActiveCompanyId, authenticatedFetch } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, cookies, fetch }) => {
|
||||
const { accessToken } = getAuthTokens(cookies);
|
||||
|
||||
if (!accessToken) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
const companyId = await getActiveCompanyId(cookies, fetch);
|
||||
if (!companyId) {
|
||||
throw error(400, 'No se encontró una compañía seleccionada');
|
||||
}
|
||||
|
||||
let agentsCount = 0;
|
||||
let clientsCount = 0;
|
||||
|
||||
try {
|
||||
const [brokersRes, clientsRes] = await Promise.all([
|
||||
authenticatedFetch(
|
||||
`v1/a76/customs-brokers?company_id=${companyId}&page=1&page_size=1`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
),
|
||||
authenticatedFetch(
|
||||
`v1/a76/clients-providers?company_id=${companyId}&page=1&page_size=1`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
)
|
||||
]);
|
||||
|
||||
if (brokersRes.ok) {
|
||||
const brokersData = await brokersRes.json();
|
||||
agentsCount = brokersData.total ?? 0;
|
||||
}
|
||||
if (clientsRes.ok) {
|
||||
const clientsData = await clientsRes.json();
|
||||
clientsCount = clientsData.total ?? 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching prerequisites count for parts:', e);
|
||||
}
|
||||
|
||||
const isCreate = params.id === 'new' || params.id === undefined;
|
||||
|
||||
return {
|
||||
agentsCount,
|
||||
clientsCount,
|
||||
isCreate
|
||||
};
|
||||
};
|
||||
@@ -1,9 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import PartForm from '$lib/components/dashboard/goods/parts/partForm.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import PartForm from '$lib/components/dashboard/goods/parts/partForm.svelte';
|
||||
import PrerequisitesModal from '$lib/components/dashboard/PrerequisitesModal.svelte';
|
||||
|
||||
let id = $derived($page.params.id === 'new' ? null : Number($page.params.id));
|
||||
let type: 'inv' | 'fa' = 'fa';
|
||||
let id = $derived($page.params.id === 'new' ? null : Number($page.params.id));
|
||||
let type: 'inv' | 'fa' = 'fa';
|
||||
|
||||
let { data }: { data: { agentsCount?: number; clientsCount?: number; isCreate?: boolean } } = $props();
|
||||
|
||||
let showPrerequisitesModal = $state(false);
|
||||
let hasTriedToShowPrerequisitesModal = $state(false);
|
||||
$effect(() => {
|
||||
if (hasTriedToShowPrerequisitesModal) return;
|
||||
if (!data?.isCreate) return;
|
||||
const agentsCount = data.agentsCount ?? 0;
|
||||
const clientsCount = data.clientsCount ?? 0;
|
||||
if (agentsCount === 0 || clientsCount === 0) {
|
||||
hasTriedToShowPrerequisitesModal = true;
|
||||
showPrerequisitesModal = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if data?.isCreate}
|
||||
<PrerequisitesModal
|
||||
bind:open={showPrerequisitesModal}
|
||||
agentsCount={data?.agentsCount ?? 0}
|
||||
clientsCount={data?.clientsCount ?? 0}
|
||||
onAccept={() => (showPrerequisitesModal = false)}
|
||||
onCancel={() => goto('/dashboard/goods/parts')}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<PartForm partId={id} formType={type} />
|
||||
@@ -39,6 +39,7 @@
|
||||
import { useShortcuts } from '$lib/hooks/use-shortcuts';
|
||||
import { obtenerAtajosEdicionFactura } from '$lib/config/shortcuts/dashboard/invoices/edit';
|
||||
import { api } from '$lib/api';
|
||||
import PrerequisitesModal from '$lib/components/dashboard/PrerequisitesModal.svelte';
|
||||
|
||||
// Cargar companyStore solo en el cliente - no usamos sidebar en esta página
|
||||
let companyStore: any = $state(undefined);
|
||||
@@ -793,6 +794,19 @@
|
||||
manejarRegresar: handleBack
|
||||
})
|
||||
);
|
||||
|
||||
// Prerrequisitos: modal cuando no hay agentes o clientes
|
||||
const agentsCount = $derived(data.customsBrokers?.length ?? 0);
|
||||
const clientsCount = $derived(data.clients?.length ?? 0);
|
||||
let showPrerequisitesModal = $state(false);
|
||||
let hasTriedToShowPrerequisitesModal = $state(false);
|
||||
$effect(() => {
|
||||
if (hasTriedToShowPrerequisitesModal) return;
|
||||
if (agentsCount === 0 || clientsCount === 0) {
|
||||
hasTriedToShowPrerequisitesModal = true;
|
||||
showPrerequisitesModal = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
@@ -1042,3 +1056,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PrerequisitesModal
|
||||
bind:open={showPrerequisitesModal}
|
||||
{agentsCount}
|
||||
{clientsCount}
|
||||
onAccept={() => (showPrerequisitesModal = false)}
|
||||
onCancel={() => goto('/dashboard/invoices')}
|
||||
/>
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
import { getExchangeRateByDate } from '$lib/api/dashboard/a76/exchange-rate';
|
||||
import ExchangeRateDialog from '$lib/components/dashboard/exchange_rate/create-edit-dialog.svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import PrerequisitesModal from '$lib/components/dashboard/PrerequisitesModal.svelte';
|
||||
|
||||
let { data }: { data: ExtendedPageData } = $props();
|
||||
|
||||
@@ -118,6 +119,20 @@
|
||||
let showExchangeRateDialog = $state(false);
|
||||
let missingExchangeRateDate = $state('');
|
||||
|
||||
// Prerrequisitos: modal solo en creación cuando no hay agentes o clientes
|
||||
const agentsCount = $derived(data.customsBrokers?.length ?? 0);
|
||||
const clientsCount = $derived(data.clients?.length ?? 0);
|
||||
let showPrerequisitesModal = $state(false);
|
||||
let hasTriedToShowPrerequisitesModal = $state(false);
|
||||
$effect(() => {
|
||||
if (hasTriedToShowPrerequisitesModal) return;
|
||||
if (!data.isCreate) return;
|
||||
if (agentsCount === 0 || clientsCount === 0) {
|
||||
hasTriedToShowPrerequisitesModal = true;
|
||||
showPrerequisitesModal = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function checkPaymentDateRate(date: string): Promise<boolean> {
|
||||
if (!date || !companyStore.activeCompany?.id) return true;
|
||||
|
||||
@@ -418,6 +433,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Validar tipo de cambio en create y update
|
||||
if (generalFormData) {
|
||||
const rate = generalFormData.exchange_rate;
|
||||
if (
|
||||
rate === null ||
|
||||
rate === undefined ||
|
||||
String(rate).trim() === '' ||
|
||||
Number(rate) <= 0
|
||||
) {
|
||||
saving = false;
|
||||
activeTab = 'general';
|
||||
const date = generalFormData.entry_date || '';
|
||||
toast.error(
|
||||
Number(rate) <= 0 && rate !== null && rate !== undefined
|
||||
? 'El tipo de cambio debe ser mayor a 0. Registra el tipo de cambio para la fecha de entrada.'
|
||||
: 'No hay tipo de cambio registrado para la fecha de entrada. Por favor, regístralo antes de guardar.'
|
||||
);
|
||||
if (date) {
|
||||
missingExchangeRateDate = date;
|
||||
showExchangeRateDialog = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Construir el payload unificado
|
||||
const payload: any = {
|
||||
// Datos generales
|
||||
@@ -1249,4 +1289,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
<!-- Modal de prerrequisitos: agentes aduanales y clientes -->
|
||||
{#if data.isCreate}
|
||||
<PrerequisitesModal
|
||||
bind:open={showPrerequisitesModal}
|
||||
{agentsCount}
|
||||
{clientsCount}
|
||||
onAccept={() => (showPrerequisitesModal = false)}
|
||||
onCancel={() => goto('/dashboard/pedimentos')}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, cookies, url }) => {
|
||||
default: async ({ request, cookies, url, fetch }) => {
|
||||
const data = await request.formData();
|
||||
const username = data.get('username')?.toString();
|
||||
const password = data.get('password')?.toString();
|
||||
|
||||
Reference in New Issue
Block a user