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