Merge branch 'development' into task/archivos_winsaai

This commit is contained in:
2026-03-05 08:19:07 -06:00
140 changed files with 25119 additions and 1400 deletions

12723
frontend/index.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -64,5 +64,6 @@
"lucide-svelte": "^0.553.0",
"marked": "^12.0.0",
"svelte-sonner": "^1.0.7"
}
}
},
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017"
}

View File

@@ -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)
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -121,7 +121,7 @@
</script>
<Sheet.Root bind:open={helpStore.isOpen}>
<Sheet.Trigger>
<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"

View File

@@ -7,11 +7,11 @@
// Group local shortcuts
let localShortcuts = $derived($store.shortcuts);
let globalList: HTMLDivElement;
let localList: HTMLDivElement;
let modalRef: HTMLDivElement;
let globalList = $state<HTMLDivElement | undefined>();
let localList = $state<HTMLDivElement | undefined>();
let modalRef = $state<HTMLDivElement | undefined>();
function handleArrowScroll(event: KeyboardEvent, target: HTMLDivElement) {
function handleArrowScroll(event: KeyboardEvent, target: HTMLDivElement | undefined) {
if (!target) return;
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
@@ -51,6 +51,7 @@
>
<div
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}
onkeydown={handleFocusTrap}
>
@@ -88,6 +89,8 @@
</h3>
<div
class="space-y-2 max-h-64 overflow-y-auto pr-1"
role="region"
aria-label="Global Navigation shortcuts"
bind:this={globalList}
tabindex="0"
onkeydown={(event) => handleArrowScroll(event, globalList)}
@@ -124,6 +127,8 @@
{:else}
<div
class="space-y-2 max-h-64 overflow-y-auto pr-1"
role="region"
aria-label="Active Actions shortcuts"
bind:this={localList}
tabindex="0"
onkeydown={(event) => handleArrowScroll(event, localList)}

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

View File

@@ -101,7 +101,7 @@
{#if item.icon}
<item.icon />
{:else}
<div class="size-4" />
<div class="size-4"></div>
{/if}
<!-- Ocultamos el texto en modo colapsado para asegurar que solo sea el icono -->
<span class="sr-only">{item.title}</span>
@@ -127,7 +127,7 @@
{#if item.icon}
<item.icon class="size-4 shrink-0" />
{:else}
<div class="size-4 shrink-0" />
<div class="size-4 shrink-0"></div>
{/if}
</div>

View File

@@ -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()}

View File

@@ -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,
},
];

View File

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

View 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;
}
}

View File

@@ -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 });
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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')}
/>

View File

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

View File

@@ -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();

View File

@@ -1 +0,0 @@
TIPO(MEX=Mexicano,AME=AMERICANO) CLAVE AADUANAL PATENTE NOMBRE RFC DIRECCION CODIGO POSTAL CIUDAD ESTADO PAIS TELEFONO NUMERO FAX CORREO ELECTRONICO CURP

View File

@@ -1 +0,0 @@
CLAVE CLASE DESCRIPCION ESPA<50>OL DESCRIPCION INGLES TIPO DE MATERIAL U.M. COMERCIAL FRACCION ARANCELARIA FRACCION AMERICANA TASA DE DEPRECIACION REVISION FISICA (1/0) CODIGO DE PRODUCTO/SERVICIO CP

View File

@@ -1 +0,0 @@
PROCEDENCIA CLIENTE(E=Extranjero, N=Nacional) TIPO(C=Cliente,P=Proveedor,A=Ambos) CLAVE CLIENTE NOMBRE RFC CALLES NUM. EXTERIOR CODIGO POSTAL COLONIA o PARQUE IND. CIUDAD ESTADO PAIS TELEFONO NUMERO FAX CORREO ELECTRONICO CURP TIPO DE PROGRAMA SECON NUMERO DE PROGRAMA SECON FECHA AUT. SECON ##/##/#### ES PROGRAMA PROSEC? (SI o NO) NUMERO DE PROGRAMA PROSEC VINCULACION ES EMPRESA CERTIFICADA? REGISTRO DE EMPRESA CERT. INFORMACION ADICIONAL CONTACTO CLAVE MANUFACTURERO TAX I.D. CLAVE BROKER AMERICANO EXPO CLAVE BROKER AMERICANO IMPO CLAVE TRANSFERENCIA A.A. TRANSFORMADOR/SUBMAQUILA CLAVE INTERFACE

View File

@@ -1 +0,0 @@
TRANSPORTISTA LINEA CLAVE CONDUCTOR LICENCIA PERMISO LINEA EXPRESS IDENTIFICACION ACE FECHA NACIMIENTO SEXO PAIS NACIMIENTO TRANSPORTA MAT. PELIGROSO? PERMISO MAT. PELIGROSO NOMBRE(S) APELLIDO PATERNO FORMA IDENTIFICACION 1 NUM. IDENTIFICACION 1 ESTADO PAIS FORMA IDENTIFICACION 2 NUM. IDENTIFICACION 2 ESTADO PAIS

View File

@@ -1 +0,0 @@
FRACCION ARANCELARIA PREFIJO UNIDAD DE MEDIDA DESCRIPCION TIPO DE ADVALOREM ADVALOREM % ADVALOREM DLLS

View File

@@ -1 +0,0 @@
NUMERO DE PARTE DESCRIPCION EN ESPA<50>OL DESCRIPCION EN INGLES CLASE UNIDAD DE MEDIDA COMERCIAL COSTO UNITARIO TIPO MONEDA COSTO CLAVE MONEDA PESO UNITARIO TIPO PESO FRACCION PAIS PREFERENCIA SECTOR RUTA DE LA IMAGEN

View File

@@ -1 +0,0 @@
NUMERO DE PEDIMENTO (##-####-######) TIPO MOV(I=Impotaci<63>n,E=Expotaci<63>n) CLAVE PEDIMENTO REGIMEN FECHA INICIO FECHA FINAL FECHA DE PAGO ADUANA Y SECCION DE CRUCE ACUSE ELECTRONICO INDIVIDUAL o CONSOLIDADO (IND,CON) MET TRANS ENTRADA MET TRANS ARRIVO MET TRANS SALIDA IEPS DTA CNT PREVALIDACION MONTO TIGIE PAGO IMPUESTO? (S/N) ES MIXTO (SI/NO) OBS RECTIFICA OPCION DESTINO(Interior del Pais/Regi<67>n Fronteriza/Franja Fronteriza) VALOR IVA VALOR ME VALOR ADUANAS FLETE VALOR SEGUROS SEGUROS EMBALAJES OTROS INCREMENTABLES ESTATUS (ABIERTO/CERRADO) PERSONA REV FECHA CIERRE FECHA REVISION FECHA AUTORIZACION FECHA RECIBIDO REPRESENTANTE AA CLAVE DEST ORIGEN FECHA ENTRADA RECINTO FECHA EXTRACCION RECINTO ERRORES FORMA PAGO DTA FORMA PAGO IGI FORMA PAGO PREVAL FORMA PAGO IVA RECARGOS MULTAS IVA DE PREV CUOTAS CONPENSATORIAS IDENTIFICADORES IEPS 2 FORMA DE PAGO IEPS 2 DTA 2 FORMA DE PAGO DTA 2 IVA 2 FORMA DE PAGO IVA 2 IGI 2 FORMA DE PAGO IGI 2 PREVALIDACION FORMA DE PAGO PREVALIDACION 2 CNT 2 FORMA DE PAGO CNT 2

View File

@@ -1 +0,0 @@
FECHA (##/##/####) TIPO DE CAMBIO

View File

@@ -1 +0,0 @@
CLAVE TRAILER/CAJA NUMERO ACE TIPO DE TRAILER PRECINTO CODIGO DE ENTIDAD PLACAS ESTADO PAIS

View File

@@ -1 +0,0 @@
CLAVE CLAVE ACE CLAVE TRANSPORTE VIN TIPO TRANSPORTE CODIGO DE ENTIDAD TRANSPONDEDOR NUMERO DOT PLACAS CIUDAD ESTADO PAIS PRECINTO EMPRESA ASEGURADORA NUM. ASEGURADORA MONTO ASEGURADO FECHA DE ASEGURADORA

View File

@@ -1 +0,0 @@
PEDIMENTO REMESA NUMERO FACTURA FECHA FACTURA TIPO DE CAMBIO REGIMEN CLAVE PROVEEDOR CLAVE VENDIDO A: CLAVE ENVIADO A AGENTE ADUANAL CLAVE TRANSPORTISTA NOMBRE CONDUCTOR TIPO TRANSPORTE NUMERO TRANSPORTE TIPO MONEDA CLAVE MONEDA FLETES VALOR SEGUROS SEGUROS EMBALAJES OTROS INCREMENTABLES CLAVE INCOTERM PRECINTO TIPO PESO MANIFIESTO E-DOCUMENT NUM. OPERACION ENVIADO POR ADUANA DE CRUCE OBSERVACIONES E OBSERVACIONES I FACTURA ALTERNA

View File

@@ -1 +0,0 @@
PEDIMENTO REMESA NUMERO FACTURA FECHA FACTURA TIPO DE CAMBIO REGIMEN CLAVE PROVEEDOR CLAVE VENDIDO A: CLAVE ENVIADO A AGENTE ADUANAL CLAVE TRANSPORTISTA NOMBRE CONDUCTOR TIPO TRANSPORTE NUMERO TRANSPORTE TIPO MONEDA CLAVE MONEDA FLETES VALOR SEGUROS SEGUROS EMBALAJES OTROS INCREMENTABLES CLAVE INCOTERM PRECINTO FECHA EMISION TIPO PESO E-DOCUMENT NUM. OPERACION ADUANA DE CRUCE OBSERVACIONES E OBSERVACIONES I

View File

@@ -1 +0,0 @@
PEDIMENTO REMESA NUMERO FACTURA FECHA FACTURA TIPO DE CAMBIO REGIMEN CLAVE PROVEEDOR CLAVE VENDIDO A: CLAVE ENVIADO A AGENTE ADUANAL CLAVE TRANSPORTISTA NOMBRE CONDUCTOR TIPO TRANSPORTE NUMERO TRANSPORTE TIPO MONEDA CLAVE MONEDA FLETES VALOR SEGUROS SEGUROS EMBALAJES OTROS INCREMENTABLES CLAVE INCOTERM PRECINTO FECHA EMISION TIPO PESO E-DOCUMENT NUM. OPERACION ADUANA DE CRUCE OBSERVACIONES E OBSERVACIONES I FACTURA ALTERNA

View File

@@ -1 +0,0 @@
NUMERO FACTURA EXPO. LINEA EXPO. TIPO DE IMPO. FACTURA IMPO. LINEA IMPO. GENERA DESCARGA CANTIDAD EXPORTADA/DESCARGAR COSTO UNITARIO PESO NETO PESO BRUTO SE PAGO IMPUESTO? (SI o NO) FORMA DE PAGO DESCRIPCION EXTRA INFORMACION ADICIONAL AGREGAR(A)/SUSTITUIR(S) LOTE NUMERO ENTRADA ES PARTIDA/SUBPARTIDA LINEA PRINCIPAL FRACCION AMERICANA FRACCION ARANCELARIA

View File

@@ -1 +0,0 @@
NUMERO FACTURA LINEA CLASE CANTIDAD IMPORTADA UNIDAD DE MEDIDA COSTO UNITARIO PESO NETO PESO BRUTO CANTIDAD BULTOS CLAVE BULTOS PAIS ORIGEN FRACCION ARANCELARIA PREFERENCIA ARANCELARIA SECTOR FRACCION AMERICANA ORDEN DE COMPRA DESCRIPCION ESPA<50>OL DESCRIPCION INGLES MARCA MODELO ES PARTIDA O SUBPARTIDA LINEA PRINCIPAL NUM. PARTE SE PAGO IMPUESTO? (SI o NO) FORMA DE PAGO METODO DE VALORACION DESCRIPCION EXTRA INFORMACION ADICIONAL AGREGAR(A)/SUSTITUIR(S) VALOR TOTAL LOTE NUMERO ENTRADA ID TYPE

View File

@@ -1 +0,0 @@
NUMERO FACTURA LINEA CLASE CANTIDAD IMPORTADA UNIDAD DE MEDIDA COSTO UNITARIO PESO NETO PESO BRUTO CANTIDAD BULTOS CLAVE BULTOS PAIS ORIGEN FRACCION ARANCELARIA PREFERENCIA ARANCELARIA SECTOR FRACCION AMERICANA ORDEN DE COMPRA DESCRIPCION ESPA<50>OL DESCRIPCION INGLES MARCA MODELO ES PARTIDA O SUBPARTIDA LINEA PRINCIPAL NUM. PARTE SE PAGO IMPUESTO? (SI o NO) FORMA DE PAGO METODO DE VALORACION DESCRIPCION EXTRA INFORMACION ADICIONAL AGREGAR(A)/SUSTITUIR(S) TOTAL NUMERO ENTRADA LOTE ID TYPE

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Silent SSO Check</title>
</head>
<body>
<script>
// Required by Keycloak JS for silent SSO check.
// This page is loaded in a hidden iframe; it posts the full URL back
// to the parent window so Keycloak can parse the SSO response.
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>

View File

@@ -9,7 +9,7 @@ const config = {
kit: {
adapter: adapter(),
csrf: {
checkOrigin: false
trustedOrigins: (process.env.TRUSTED_ORIGINS ?? process.env.CORS_ORIGINS ?? '').split(',').filter(Boolean)
}
}
};