Merge pull request 'feat: Implement multi-tenancy support in middleware and security layers' (#15) from feature/security into development

Reviewed-on: ADUANASOFT/anexo76#15
This commit is contained in:
2026-01-20 18:54:10 +00:00
62 changed files with 6441 additions and 791 deletions

View File

@@ -3,6 +3,7 @@
*/
import { getToken } from './auth';
import { browser } from '$app/environment';
import { toast } from 'svelte-sonner';
// Normalize API_BASE_URL to remove trailing slash
const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
@@ -172,6 +173,23 @@ async function fetchApi<T = any>(
// Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token
if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) {
// Si es 403 (Forbidden), mostrar toast de permisos insuficientes
if (response.status === 403) {
if (browser) {
toast.error('No tienes permisos para realizar esta acción', {
duration: 4000,
description: 'Contacta a tu administrador si crees que esto es un error'
});
}
// Retornar el error 403 sin intentar refresh
const data = await response.json();
return {
error: data.detail || 'No tienes permisos para realizar esta acción',
status: 403
};
}
// Si es 401, intentar refrescar el token
isRefreshing = true;
try {

View File

@@ -1,26 +1,7 @@
import { api } from '$lib/api';
import type { ApiResponse } from '$lib/api';
export interface CustomsBroker {
type?: string | null;
broker_key: string;
name?: string | null;
address?: string | null;
postal_code?: string | null;
city?: string | null;
state?: string | null;
phone?: string | null;
fax?: string | null;
email?: string | null;
country?: string | null;
tax_id?: string | null;
personal_id?: string | null;
position?: string | null;
license: string;
company?: string | null;
contact?: string | null;
tenant_id: string;
company_id: string;
export interface CustomsBroker {
id: number;
type?: string | null;
broker_key: string;
@@ -117,13 +98,14 @@ export const customsBrokersApi = {
return api.post<CustomsBroker>(`/v1/a76/customs-brokers/?company_id=${companyId}`, data);
},
update: (brokerKey: string, data: CreateCustomsBrokerData, companyId: string) => {
return api.patch<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`, data);
},
/**
* Actualiza la información de un agente aduanal
*/
update: (brokerKey: string, data: CreateCustomsBrokerData) => {
const companyId = data.company_id;
return api.put<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, data);
},
delete: (brokerKey: string, companyId: string) => {
return api.delete<void>(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`);
},
/**
* Elimina un agente aduanal
*/
@@ -131,13 +113,7 @@ export const customsBrokersApi = {
return api.delete<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`);
},
/**
* Actualiza la información de un agente aduanal
*/
update: (brokerKey: string, data: CreateCustomsBrokerData) => {
const companyId = data.company_id;
return api.put<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, data);
},
updateVU: (brokerKey: string, data: CustomsBrokerVU, companyId: string) => {
return api.put<CustomsBrokerVU>(`/v1/a76/customs-broker-vu/${brokerKey}?company_id=${companyId}`, data);

View File

@@ -0,0 +1,8 @@
/**
* Exportaciones centralizadas de APIs de administración
*/
export * from './permissions';
export * from './roles';
export * from './role-permissions';
export * from './user-roles';

View File

@@ -0,0 +1,110 @@
/**
* API para gestión de permisos del sistema
*/
import { api } from '$lib/api';
export interface Permission {
id: number;
code: string;
description?: string;
module: string;
action: string;
is_active: boolean;
created_at?: string;
updated_at?: string;
}
export interface CreatePermissionData {
code?: string;
description?: string;
module: string;
action: string;
is_active?: boolean;
}
export interface UpdatePermissionData {
code?: string;
description?: string;
module?: string;
action?: string;
is_active?: boolean;
}
export interface PermissionListResponse {
items: Permission[];
total: number;
page: number;
page_size: number;
}
export const permissionsAPI = {
/**
* Listar permisos con filtros
*/
async list(params?: {
page?: number;
page_size?: number;
module?: string;
action?: string;
is_active?: boolean;
search?: string;
}): Promise<PermissionListResponse> {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
if (params?.module) queryParams.set('module', params.module);
if (params?.action) queryParams.set('action', params.action);
if (params?.search) queryParams.set('search', params.search);
const query = queryParams.toString();
const response = await api.get(`/v1/core/permissions${query ? '?' + query : ''}`);
return response.data;
},
/**
* Obtener un permiso por ID
*/
async getById(id: number): Promise<Permission> {
const response = await api.get(`/v1/core/permissions/${id}`);
return response.data;
},
/**
* Crear un nuevo permiso
*/
async create(data: CreatePermissionData): Promise<Permission> {
const response = await api.post('/v1/core/permissions', data);
return response.data;
},
/**
* Actualizar un permiso
*/
async update(id: number, data: UpdatePermissionData): Promise<Permission> {
const response = await api.put(`/v1/core/permissions/${id}`, data);
return response.data;
},
/**
* Eliminar un permiso
*/
async delete(id: number): Promise<void> {
await api.delete(`/v1/core/permissions/${id}`);
},
/**
* Obtener módulos únicos
*/
async getModules(): Promise<string[]> {
const response = await api.get('/v1/core/permissions/modules');
return response.data;
},
/**
* Obtener acciones únicas
*/
async getActions(): Promise<string[]> {
const response = await api.get('/v1/core/permissions/actions');
return response.data;
}
};

View File

@@ -0,0 +1,76 @@
/**
* API para gestión de permisos de roles
*/
import { api } from '$lib/api';
export interface RolePermission {
id: number;
company_role_id: number;
permission_id: number;
granted_at?: string;
granted_by?: number;
tenant_id: number;
permission?: {
id: number;
code: string;
module: string;
action: string;
description?: string;
is_active: boolean;
};
}
export interface AssignPermissionData {
permission_id: number;
}
export interface RolePermissionsResponse {
role_id: number;
permissions: RolePermission[];
total: number;
}
export const rolePermissionsAPI = {
/**
* Listar todos los permisos asignados a un rol
*/
async listByRole(roleId: number, companyId: number): Promise<RolePermissionsResponse> {
const response = await api.get(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`);
return response.data;
},
/**
* Asignar un permiso a un rol
*/
async assign(
roleId: number,
companyId: number,
data: AssignPermissionData
): Promise<RolePermission> {
const response = await api.post(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`, data);
return response.data;
},
/**
* Remover un permiso de un rol
*/
async remove(roleId: number, permissionId: number, companyId: number): Promise<void> {
await api.delete(`/v1/core/permissions/roles/${roleId}/permissions/${permissionId}?company_id=${companyId}`);
},
/**
* Asignar múltiples permisos a un rol
*/
async assignMultiple(
roleId: number,
companyId: number,
permissionIds: number[]
): Promise<RolePermission[]> {
const response = await api.post(
`/v1/core/permissions/roles/${roleId}/permissions/batch?company_id=${companyId}`,
{ permission_ids: permissionIds }
);
return response.data;
}
};

View File

@@ -0,0 +1,93 @@
/**
* API para gestión de roles por compañía
*/
import { api } from '$lib/api';
export interface CompanyRole {
id: number;
name: string;
code: string;
description?: string;
is_active: boolean;
company_id: number;
tenant_id: number;
created_at?: string;
updated_at?: string;
}
export interface CreateRoleData {
name: string;
code: string;
description?: string;
is_active?: boolean;
}
export interface UpdateRoleData {
name?: string;
code?: string;
description?: string;
is_active?: boolean;
}
export interface RoleListResponse {
items: CompanyRole[];
total: number;
page: number;
page_size: number;
}
export const rolesAPI = {
/**
* Listar roles con filtros
*/
async list(
companyId: number,
params?: {
page?: number;
page_size?: number;
is_active?: boolean;
search?: string;
}
): Promise<RoleListResponse> {
const queryParams = new URLSearchParams();
queryParams.set('company_id', companyId.toString());
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
if (params?.is_active !== undefined) queryParams.set('is_active', params.is_active.toString());
if (params?.search) queryParams.set('search', params.search);
const response = await api.get(`/v1/core/permissions/roles?${queryParams.toString()}`);
return response.data;
},
/**
* Obtener un rol por ID
*/
async getById(id: number, companyId: number): Promise<CompanyRole> {
const response = await api.get(`/v1/core/permissions/roles/${id}?company_id=${companyId}`);
return response.data;
},
/**
* Crear un nuevo rol
*/
async create(companyId: number, data: CreateRoleData): Promise<CompanyRole> {
const response = await api.post(`/v1/core/permissions/roles?company_id=${companyId}`, data);
return response.data;
},
/**
* Actualizar un rol
*/
async update(id: number, companyId: number, data: UpdateRoleData): Promise<CompanyRole> {
const response = await api.patch(`/v1/core/permissions/roles/${id}?company_id=${companyId}`, data);
return response.data;
},
/**
* Eliminar un rol
*/
async delete(id: number, companyId: number): Promise<void> {
await api.delete(`/v1/core/permissions/roles/${id}?company_id=${companyId}`);
}
};

View File

@@ -0,0 +1,108 @@
/**
* API para gestión de permisos individuales de usuario
*/
import { api } from '$lib/api';
import type { Permission } from './permissions';
export interface UserPermission {
id: number;
user_id: string;
permission_id: number;
company_id: number;
tenant_id: number;
is_granted: boolean;
is_active: boolean;
assigned_by?: string;
expires_at?: string;
created_at?: string;
updated_at?: string;
permission?: Permission;
}
export interface AssignUserPermissionData {
user_id: string;
permission_id: number;
is_granted?: boolean;
expires_at?: string;
}
export interface UserPermissionsListResponse {
items: UserPermission[];
total: number;
}
export interface EffectiveUserPermissions {
user_id: string;
company_id: number;
role_permissions: Permission[];
granted_permissions: Permission[];
revoked_permissions: Permission[];
effective_permissions: Permission[];
}
export const userPermissionsAPI = {
/**
* Obtener permisos individuales de un usuario
*/
async getIndividual(userId: string, companyId: number): Promise<UserPermissionsListResponse> {
const response = await api.get(
`/v1/core/permissions/users/${userId}/permissions?company_id=${companyId}`
);
return response.data;
},
/**
* Obtener permisos efectivos de un usuario (roles + individuales - revocados)
*/
async getEffective(userId: string, companyId: number): Promise<EffectiveUserPermissions> {
const response = await api.get(
`/v1/core/permissions/users/${userId}/permissions/effective?company_id=${companyId}`
);
return response.data;
},
/**
* Asignar un permiso individual a un usuario
*/
async assign(
userId: string,
companyId: number,
data: Omit<AssignUserPermissionData, 'user_id'>
): Promise<UserPermission> {
const response = await api.post(
`/v1/core/permissions/users/${userId}/permissions?company_id=${companyId}`,
data
);
return response.data;
},
/**
* Conceder un permiso extra a un usuario
*/
async grant(userId: string, companyId: number, permissionId: number): Promise<UserPermission> {
return this.assign(userId, companyId, {
permission_id: permissionId,
is_granted: true
});
},
/**
* Revocar un permiso específico (aunque venga del rol)
*/
async revoke(userId: string, companyId: number, permissionId: number): Promise<UserPermission> {
return this.assign(userId, companyId, {
permission_id: permissionId,
is_granted: false
});
},
/**
* Eliminar un permiso individual
*/
async remove(userId: string, companyId: number, permissionId: number): Promise<void> {
await api.delete(
`/v1/core/permissions/users/${userId}/permissions/${permissionId}?company_id=${companyId}`
);
}
};

View File

@@ -0,0 +1,94 @@
/**
* API para gestión de roles de usuarios
*/
import { api } from '$lib/api';
export interface UserRole {
id: number;
user_id: string;
company_id: number;
company_role_id: number;
is_active: boolean;
created_at: string;
assigned_by?: string;
company_role?: {
id: number;
name: string;
code: string;
description?: string;
};
user?: {
id: number;
username: string;
email?: string;
full_name?: string;
};
}
export interface AssignUserRoleData {
user_id: string;
company_role_id: number;
}
export interface UserRolesResponse {
items: UserRole[];
total: number;
page: number;
page_size: number;
}
export const userRolesAPI = {
/**
* Listar todos los roles asignados a usuarios
*/
async list(
companyId: number,
params?: {
user_id?: string;
company_role_id?: number;
page?: number;
page_size?: number;
}
): Promise<UserRolesResponse> {
const queryParams = new URLSearchParams();
queryParams.set('company_id', companyId.toString());
if (params?.user_id) queryParams.set('user_id', params.user_id);
if (params?.company_role_id) queryParams.set('company_role_id', params.company_role_id.toString());
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
const response = await api.get(`/v1/core/permissions/user-roles?${queryParams.toString()}`);
return response.data;
},
/**
* Listar roles de un usuario específico
*/
async listByUser(userId: number, companyId: number): Promise<UserRolesResponse> {
const response = await api.get(`/v1/core/permissions/users/${userId}/roles?company_id=${companyId}`);
return response.data;
},
/**
* Listar usuarios con un rol específico
*/
async listByRole(roleId: number, companyId: number): Promise<UserRolesResponse> {
const response = await api.get(`/v1/core/permissions/roles/${roleId}/users?company_id=${companyId}`);
return response.data;
},
/**
* Asignar un rol a un usuario
*/
async assign(companyId: number, data: AssignUserRoleData): Promise<UserRole> {
const response = await api.post(`/v1/core/permissions/user-roles?company_id=${companyId}`, data);
return response.data;
},
/**
* Remover un rol de un usuario
*/
async remove(userRoleId: number, companyId: number): Promise<void> {
await api.delete(`/v1/core/permissions/user-roles/${userRoleId}?company_id=${companyId}`);
}
};

View File

@@ -62,8 +62,8 @@ export const usersAPI = {
/**
* Obtiene estadísticas de usuarios del tenant
*/
async getStats(): Promise<UserStats> {
const response = await api.get<UserStats>('/v1/core/users/stats');
async getStats(companyId: number): Promise<UserStats> {
const response = await api.get<UserStats>(`/v1/core/users/stats?company_id=${companyId}`);
if (response.error) {
throw new Error(response.error);
}
@@ -73,17 +73,18 @@ export const usersAPI = {
/**
* Lista usuarios del tenant con paginación
*/
async list(params?: {
async list(companyId: number, params?: {
page?: number;
page_size?: number;
search?: string;
}): Promise<UserListResponse> {
const queryParams = new URLSearchParams();
queryParams.set('company_id', companyId.toString());
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
if (params?.search) queryParams.set('search', params.search);
const endpoint = `/v1/core/users/${queryParams.toString() ? `?${queryParams}` : ''}`;
const endpoint = `/v1/core/users/?${queryParams}`;
const response = await api.get<UserListResponse>(endpoint);
if (response.error) {
throw new Error(response.error);
@@ -94,8 +95,8 @@ export const usersAPI = {
/**
* Obtiene un usuario específico
*/
async get(userId: string): Promise<User> {
const response = await api.get<User>(`/v1/core/users/${userId}`);
async get(userId: string, companyId: number): Promise<User> {
const response = await api.get<User>(`/v1/core/users/${userId}?company_id=${companyId}`);
if (response.error) {
throw new Error(response.error);
}
@@ -105,8 +106,8 @@ export const usersAPI = {
/**
* Crea un nuevo usuario
*/
async create(data: CreateUserRequest): Promise<User> {
const response = await api.post<User>('/v1/core/users/', data);
async create(data: CreateUserRequest, companyId: number): Promise<User> {
const response = await api.post<User>(`/v1/core/users/?company_id=${companyId}`, data);
if (response.error) {
throw new Error(response.error);
}
@@ -116,8 +117,8 @@ export const usersAPI = {
/**
* Actualiza un usuario existente
*/
async update(userId: string, data: UpdateUserRequest): Promise<User> {
const response = await api.put<User>(`/v1/core/users/${userId}`, data);
async update(userId: string, data: UpdateUserRequest, companyId: number): Promise<User> {
const response = await api.put<User>(`/v1/core/users/${userId}?company_id=${companyId}`, data);
if (response.error) {
throw new Error(response.error);
}
@@ -127,8 +128,9 @@ export const usersAPI = {
/**
* Elimina un usuario
*/
async delete(userId: string, softDelete: boolean = true): Promise<void> {
async delete(userId: string, companyId: number, softDelete: boolean = true): Promise<void> {
const queryParams = new URLSearchParams();
queryParams.set('company_id', companyId.toString());
queryParams.set('soft_delete', softDelete.toString());
const response = await api.delete(`/v1/core/users/${userId}?${queryParams}`);
@@ -140,8 +142,8 @@ export const usersAPI = {
/**
* Cambia la contraseña de un usuario
*/
async changePassword(userId: string, data: ChangePasswordRequest): Promise<void> {
const response = await api.post(`/v1/core/users/${userId}/change-password`, data);
async changePassword(userId: string, data: ChangePasswordRequest, companyId: number): Promise<void> {
const response = await api.post(`/v1/core/users/${userId}/change-password?company_id=${companyId}`, data);
if (response.error) {
throw new Error(response.error);
}

View File

@@ -27,7 +27,7 @@
// Campo de comentario estatus
comments_status: invoice.comments_status || '',
// Campos que van en diferentes recursos pero se editan aquí
transport_mode: invoice.logistics?.[0]?.transport_mode || null,
transport_mode: invoice.logistics?.transport_mode || null,
is_mixed: invoice.compliance_mx?.is_mixed || null,
print_stamp: invoice.financials?.seal_value_2500 || false,
rule_3121_parties_ii: false,

View File

@@ -10,7 +10,8 @@ import {
GalleryVerticalEnd,
LayoutDashboard,
Package,
Settings2,
Settings2,
Shield,
Users,
} from 'lucide-svelte';
import * as m from "$lib/paraglide/messages.js";
@@ -382,14 +383,12 @@ export function getSidebarData(): SidebarData {
url: "/dashboard/customs_brokers",
icon: BadgeCheck,
items: [],
},
},
{
title: m["sidebar.reference_data.configuracion"](),
url: "#",
icon: Settings2,
items: [
{
title: m["sidebar.reference_data.usuarios"](),
url: "",

View File

@@ -23,6 +23,9 @@
// URL completa del avatar
let avatarUrl = $derived(getBackendAssetUrl(user.avatar) || '/avatars/default.jpg');
// Iniciales del usuario (2 primeras letras)
let initials = $derived(user.name.slice(0, 2).toUpperCase());
// Estado reactivo del idioma actual
let currentLocale = $derived(page.data.locale || 'en');
@@ -107,7 +110,7 @@
>
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={avatarUrl} alt={user.name} />
<Avatar.Fallback class="rounded-lg">AS</Avatar.Fallback>
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
@@ -127,7 +130,7 @@
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={avatarUrl} alt={user.name} />
<Avatar.Fallback class="rounded-lg">AS</Avatar.Fallback>
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>

View File

@@ -16,6 +16,11 @@
? getBackendAssetUrl(companyStore.activeCompany.logo)
: null
);
// Iniciales de la compañía activa (2 primeras letras)
let activeCompanyInitials = $derived(
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO'
);
</script>
@@ -40,7 +45,7 @@
onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
{:else}
<BuildingIcon class="size-4 text-white" />
<span class="text-sm font-semibold text-white">{activeCompanyInitials}</span>
{/if}
</div>
<div class="grid flex-1 text-left text-sm leading-tight min-w-0">
@@ -89,7 +94,7 @@
class="size-full rounded object-cover"
/>
{:else}
<BuildingIcon class="size-3.5 shrink-0" />
<span class="text-xs font-semibold">{company.name.slice(0, 2).toUpperCase()}</span>
{/if}
</div>
<div class="flex flex-1 flex-col min-w-0">

View File

@@ -133,7 +133,7 @@ export async function refreshAccessToken(
* @param cookies - Objeto de cookies de SvelteKit
* @param fetch - Función fetch de SvelteKit
* @param redirectUrl - URL a la que redirigir si falla la autenticación (opcional)
* @param timeout - Timeout en milisegundos (default: 10000ms)
* @param timeout - Timeout en milisegundos (default: 30000ms)
*/
export async function authenticatedFetch(
endpoint: string,
@@ -141,7 +141,7 @@ export async function authenticatedFetch(
cookies: Cookies,
fetch: typeof globalThis.fetch,
redirectUrl?: string,
timeout: number = 10000
timeout: number = 30000
): Promise<Response> {
try {
const baseUrl = getServerApiUrl();
@@ -180,6 +180,12 @@ export async function authenticatedFetch(
clearTimeout(timeoutId);
// Si es 403, no intentar refrescar - es un problema de permisos
if (response.status === 403) {
console.warn('🚫 [API] Acceso denegado (403):', endpoint);
return response; // Retornar directamente para que el llamador maneje el error
}
// Si es 401, intentar refrescar el token
if (response.status === 401) {
const newToken = await refreshAccessToken(cookies, fetch);
@@ -346,3 +352,32 @@ export async function getActiveCompanyId(
return companyId || null;
}
/**
* Helper para manejar respuestas de API y convertir errores 403 en formato adecuado
* para mostrar toasts en el cliente
*/
export async function handleApiResponse<T = any>(
response: Response
): Promise<{ data?: T; error?: { detail: string; status: number; isForbidden?: boolean } }> {
if (response.ok) {
// Para respuestas sin contenido (204)
if (response.status === 204) {
return { data: null as T };
}
const data = await response.json();
return { data };
}
// Manejar errores
const errorData = await response.json().catch(() => ({ detail: 'Error desconocido' }));
const error = {
detail: errorData.detail || errorData.message || 'Error en la petición',
status: response.status,
isForbidden: response.status === 403
};
return { error };
}

View File

@@ -0,0 +1,84 @@
/**
* Utilidades para manejar errores de API en el cliente
*/
import { toast } from 'svelte-sonner';
export interface ApiError {
detail: string;
status: number;
isForbidden?: boolean;
}
/**
* Maneja errores de API mostrando el toast apropiado
* @param error - El error a manejar (puede ser un objeto ApiError o un string)
* @returns true si se manejó un error, false si no había error
*/
export function handleApiError(error?: ApiError | string | null): boolean {
if (!error) return false;
// Si es un string, convertirlo a objeto
if (typeof error === 'string') {
// Detectar si es un error 403
if (error.includes('403') || error.toLowerCase().includes('forbidden')) {
toast.error(error, {
duration: 5000,
description: 'No tienes permisos para realizar esta acción'
});
return true;
}
// Otros errores en formato string
toast.error(error, {
duration: 4000
});
return true;
}
// Es un objeto ApiError
if (error.isForbidden || error.status === 403) {
// Mostrar el mensaje específico del backend si está disponible
const message = error.detail || 'No tienes permisos para realizar esta acción';
toast.error(message, {
duration: 5000,
description: error.detail ? 'Contacta a tu administrador si crees que esto es un error' : undefined
});
return true;
}
if (error.status === 401) {
toast.error('Sesión expirada', {
duration: 3000,
description: 'Por favor, inicia sesión nuevamente'
});
return true;
}
// Otros errores
toast.error(error.detail || 'Error en la operación', {
duration: 4000
});
return true;
}
/**
* Hook para usar en componentes Svelte con $effect
* Muestra automáticamente un toast cuando hay un error
*
* Ejemplo de uso en +page.svelte:
* ```svelte
* <script lang="ts">
* import { handleApiError } from '$lib/utils/error-handler';
* let { data } = $props();
*
* $effect(() => {
* handleApiError(data.error);
* });
* </script>
* ```
*/
export function useErrorHandler(error?: ApiError | null) {
if (error) {
handleApiError(error);
}
}

View File

@@ -0,0 +1,71 @@
/**
* Helper para verificar permisos del usuario
* Basado en el sistema de permisos RBAC del backend
*/
import { get } from 'svelte/store';
import { page } from '$app/stores';
export interface UserPermission {
module: string;
action: string;
}
/**
* Verifica si el usuario tiene un permiso específico
* @param module - El módulo (ej: 'invoices', 'pedimentos')
* @param action - La acción (ej: 'create', 'update', 'delete', 'read')
* @returns true si el usuario tiene el permiso, false si no
*/
export function hasPermission(module: string, action: string): boolean {
// TODO: Implementar verificación real contra permisos del usuario
// Por ahora retorna true para permitir desarrollo
// En producción esto debe:
// 1. Obtener los permisos del usuario desde el contexto/store
// 2. Verificar si existe un permiso con module y action
// 3. Retornar true/false basado en la verificación
console.warn('hasPermission() no está implementado - retornando true por defecto');
return true;
}
/**
* Verifica si el usuario tiene alguno de varios permisos
* @param permissions - Array de permisos a verificar
* @returns true si el usuario tiene al menos uno de los permisos
*/
export function hasAnyPermission(permissions: UserPermission[]): boolean {
return permissions.some(p => hasPermission(p.module, p.action));
}
/**
* Verifica si el usuario tiene todos los permisos especificados
* @param permissions - Array de permisos a verificar
* @returns true si el usuario tiene todos los permisos
*/
export function hasAllPermissions(permissions: UserPermission[]): boolean {
return permissions.every(p => hasPermission(p.module, p.action));
}
/**
* Guard para proteger rutas basado en permisos
* Puede ser usado en +page.server.ts o +layout.server.ts
* @param module - El módulo requerido
* @param action - La acción requerida
* @returns objeto con allowed (boolean) y redirect (string opcional)
*/
export function requirePermission(module: string, action: string): {
allowed: boolean;
redirect?: string;
} {
const allowed = hasPermission(module, action);
if (!allowed) {
return {
allowed: false,
redirect: '/dashboard?error=forbidden'
};
}
return { allowed: true };
}

View File

@@ -2,8 +2,18 @@
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { Toaster } from 'svelte-sonner';
import { page } from '$app/stores';
import { handleApiError } from '$lib/utils/error-handler';
let { children } = $props();
// Detectar errores de CUALQUIER página (layout o page)
$effect(() => {
const pageData = $page.data as any;
if (pageData?.error) {
handleApiError(pageData.error);
}
});
</script>
<svelte:head>

View File

@@ -30,7 +30,8 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
return {
authenticated: true,
user: userData,
companies // Pasar las compañías al cliente
companies, // Pasar las compañías al cliente
error: undefined // Agregar error opcional para compatibilidad con error-handler
};
} catch (error) {
// Si es un redirect, re-lanzarlo sin tocar las cookies

View File

@@ -12,12 +12,10 @@
FileText,
Users,
Package,
TruckIcon,
Clock,
BarChart3,
PieChart,
TrendingUp,
AlertCircle,
Calendar,
CircleAlert,
RefreshCw
} from 'lucide-svelte';
import type { DashboardStats } from '$lib/api/dashboard/types';
@@ -28,6 +26,13 @@
let loading = $state(true);
let error = $state<string | null>(null);
let greeting = $derived(() => {
const hour = new Date().getHours();
if (hour < 12) return 'Buenos días';
if (hour < 18) return 'Buenas tardes';
return 'Buenas noches';
});
async function loadDashboardData() {
if (!companyStore.activeCompany) {
error = 'No hay compañía activa seleccionada';
@@ -56,29 +61,39 @@
</script>
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Dashboard</h1>
<p class="text-muted-foreground">
{#snippet headerSection()}
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold tracking-tight text-foreground">
{greeting()}, equipo.
</h1>
<p class="text-muted-foreground mt-1 flex items-center gap-2">
{#if stats?.company_name}
{stats.company_name} - Sistema de gestión de comercio exterior
<span class="font-semibold text-primary">{stats.company_name}</span>
<span class="text-xs bg-muted px-2 py-0.5 rounded-full">Anexos 22/24/30</span>
{:else}
Sistema de gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT
Sistema de gestión de comercio exterior
{/if}
</p>
</div>
<Button onclick={loadDashboardData} variant="outline" disabled={loading}>
<RefreshCw class={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
{loading ? 'Cargando...' : 'Actualizar'}
</Button>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="hidden sm:flex">
<Calendar class="mr-2 h-4 w-4" />
Hoy: {new Date().toLocaleDateString()}
</Button>
<Button onclick={loadDashboardData} variant="outline" disabled={loading}>
<RefreshCw class={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
{loading ? 'Cargando...' : 'Actualizar'}
</Button>
</div>
</div>
</div>
{/snippet}
{@render headerSection()}
{#if error}
<Alert.Root variant="destructive">
<AlertCircle class="h-4 w-4" />
<CircleAlert class="h-4 w-4" />
<Alert.Title>Error</Alert.Title>
<Alert.Description>{error}</Alert.Description>
</Alert.Root>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/stores';
import { Button } from '$lib/components/ui/button';
import { Shield, Key, Users, UserCog } from 'lucide-svelte';
let { children }: { children: Snippet } = $props();
const adminRoutes = [
{ name: 'Roles', path: '/dashboard/admin/roles', icon: Shield }
];
const currentPath = $derived($page.url.pathname);
</script>
<div class="min-h-screen bg-background">
<div class="border-b">
<div class="container mx-auto px-4">
<div class="flex items-center gap-2 py-4 overflow-x-auto">
{#each adminRoutes as route}
{@const Icon = route.icon}
<Button
variant={currentPath === route.path ? 'default' : 'ghost'}
href={route.path}
class="flex items-center gap-2 whitespace-nowrap"
>
<Icon class="h-4 w-4" />
{route.name}
</Button>
{/each}
</div>
</div>
</div>
{@render children()}
</div>

View File

@@ -0,0 +1,529 @@
<script lang="ts">
import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles';
import {
rolePermissionsAPI,
type RolePermission
} from '$lib/api/dashboard/admin/role-permissions';
import { permissionsAPI, type Permission } from '$lib/api/dashboard/admin/permissions';
import { companyStore } from '$lib/stores/company.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import { Switch } from '$lib/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table';
import * as Dialog from '$lib/components/ui/dialog';
import { Label } from '$lib/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '$lib/components/ui/select';
import { Badge } from '$lib/components/ui/badge';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Plus, Trash2, Shield, Key, Pencil } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
let roles = $state<CompanyRole[]>([]);
let selectedRole = $state<CompanyRole | null>(null);
let rolePermissions = $state<RolePermission[]>([]);
let allPermissions = $state<Permission[]>([]);
let availablePermissions = $state<Permission[]>([]);
let permissionsByModule = $state<Map<string, Permission[]>>(new Map());
let loading = $state(true);
let loadingPermissions = $state(false);
// Dialog state
let showPermissionsDialog = $state(false);
let showRoleDialog = $state(false);
let selectedPermissionIds = $state<number[]>([]);
let permissionSearchQuery = $state('');
let editingRole = $state<CompanyRole | null>(null);
let roleFormData = $state({
name: '',
code: '',
description: '',
is_active: true
});
const companyId = $derived(companyStore.activeCompany?.id);
// Cargar datos cuando companyId esté disponible
$effect(() => {
if (companyId) {
loadRoles();
loadAllPermissions();
}
});
async function loadRoles() {
if (!companyId) return;
try {
loading = true;
const response = await rolesAPI.list(companyId);
roles = response.items || [];
if (roles.length > 0 && !selectedRole) {
await selectRole(roles[0]);
}
} catch (error) {
toast.error('Error al cargar roles');
console.error(error);
} finally {
loading = false;
}
}
async function loadAllPermissions() {
try {
const response = await permissionsAPI.list();
allPermissions = response.items || [];
console.log('Total permissions loaded:', allPermissions.length);
console.log('Unique permission IDs:', new Set(allPermissions.map(p => p.id)).size);
if (selectedRole) {
updateAvailablePermissions();
}
} catch (error) {
toast.error('Error al cargar permisos');
console.error(error);
}
}
async function selectRole(role: CompanyRole) {
if (!companyId) return;
selectedRole = role;
loadingPermissions = true;
try {
const response = await rolePermissionsAPI.listByRole(role.id, companyId);
rolePermissions = response.permissions;
updateAvailablePermissions();
} catch (error) {
toast.error('Error al cargar permisos del rol');
console.error(error);
} finally {
loadingPermissions = false;
}
}
function updateAvailablePermissions() {
const assignedIds = rolePermissions.map((rp) => rp.permission_id);
availablePermissions = allPermissions.filter((p) => !assignedIds.includes(p.id));
console.log('Available permissions:', availablePermissions.length);
console.log('Unique available IDs:', new Set(availablePermissions.map(p => p.id)).size);
updatePermissionsByModule();
}
function updatePermissionsByModule() {
const grouped = new Map<string, Permission[]>();
console.log('Grouping permissions:', availablePermissions.length);
// Filtrar por búsqueda
const filtered = availablePermissions.filter((p) => {
if (!permissionSearchQuery.trim()) return true;
const search = permissionSearchQuery.toLowerCase();
return (
p.code.toLowerCase().includes(search) ||
p.description?.toLowerCase().includes(search) ||
p.action.toLowerCase().includes(search)
);
});
filtered.forEach((p) => {
if (!grouped.has(p.module)) {
grouped.set(p.module, []);
}
grouped.get(p.module)!.push(p);
});
console.log('Grouped by module:', Array.from(grouped.entries()).map(([k, v]) => `${k}: ${v.length}`));
permissionsByModule = grouped;
}
function openAddDialog() {
const assignedIds = rolePermissions.map((rp) => rp.permission_id);
const availablePermissions = allPermissions.filter((p) => !assignedIds.includes(p.id));
selectedPermissionIds = [];
showPermissionsDialog = true;
}
function openCreateRoleDialog() {
editingRole = null;
roleFormData = { name: '', code: '', description: '', is_active: true };
showRoleDialog = true;
}
function openEditRoleDialog(role: CompanyRole) {
editingRole = role;
roleFormData = {
name: role.name,
code: role.code,
description: role.description || '',
is_active: role.is_active
};
showRoleDialog = true;
}
async function handleRoleSubmit() {
if (!companyId) return;
try {
if (editingRole) {
await rolesAPI.update(editingRole.id, companyId, roleFormData);
toast.success('Rol actualizado correctamente');
// Si el rol editado es el seleccionado, actualizar la referencia
if (selectedRole?.id === editingRole.id) {
selectedRole = { ...selectedRole, ...roleFormData };
}
} else {
const newRole = await rolesAPI.create(companyId, roleFormData);
toast.success('Rol creado correctamente');
// Seleccionar el nuevo rol automáticamente
selectedRole = newRole;
}
showRoleDialog = false;
await loadRoles();
} catch (error: any) {
const message =
error?.response?.data?.detail ||
error?.message ||
(editingRole ? 'Error al actualizar rol' : 'Error al crear rol');
toast.error(message);
console.error(error);
}
}
async function handleDeleteRole(role: CompanyRole) {
if (!companyId) return;
if (!confirm(`¿Estás seguro de eliminar el rol "${role.name}"?`)) return;
try {
await rolesAPI.delete(role.id, companyId);
toast.success('Rol eliminado correctamente');
// Si el rol eliminado es el seleccionado, deseleccionar
if (selectedRole?.id === role.id) {
selectedRole = null;
}
await loadRoles();
} catch (error) {
toast.error('Error al eliminar rol');
console.error(error);
}
}
async function handleAssignPermissions() {
if (!companyId || !selectedRole || selectedPermissionIds.length === 0) return;
try {
await rolePermissionsAPI.assignMultiple(selectedRole.id, companyId, selectedPermissionIds);
toast.success('Permisos asignados correctamente');
showPermissionsDialog = false;
await selectRole(selectedRole);
} catch (error) {
toast.error('Error al asignar permisos');
console.error(error);
}
}
async function handleRemovePermission(permissionId: number) {
if (!companyId || !selectedRole) return;
if (!confirm('¿Estás seguro de remover este permiso?')) return;
try {
await rolePermissionsAPI.remove(selectedRole.id, permissionId, companyId);
toast.success('Permiso removido correctamente');
await selectRole(selectedRole);
} catch (error) {
toast.error('Error al remover permiso');
console.error(error);
}
}
function togglePermission(permissionId: number) {
if (selectedPermissionIds.includes(permissionId)) {
selectedPermissionIds = selectedPermissionIds.filter((id) => id !== permissionId);
} else {
selectedPermissionIds = [...selectedPermissionIds, permissionId];
}
}
</script>
<div class="container mx-auto py-6">
<div class="mb-6">
<h1 class="text-3xl font-bold">Roles y Permisos</h1>
<p class="text-muted-foreground">Gestiona los roles de la compañía y sus permisos</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Roles List -->
<div class="lg:col-span-1">
<div class="border rounded-lg p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold flex items-center gap-2">
<Shield class="h-5 w-5" />
Roles
</h2>
<Button size="sm" onclick={openCreateRoleDialog}>
<Plus class="h-4 w-4" />
</Button>
</div>
{#if loading}
<p class="text-muted-foreground text-center py-4">Cargando...</p>
{:else if roles.length === 0}
<div class="text-center py-8">
<p class="text-muted-foreground mb-4">No hay roles disponibles</p>
<Button size="sm" onclick={openCreateRoleDialog}>
<Plus class="mr-2 h-4 w-4" />
Crear primer rol
</Button>
</div>
{:else}
<div class="space-y-2">
{#each roles as role (role.id)}
<div
class="flex items-center gap-2 p-2 rounded-lg border {selectedRole?.id ===
role.id
? 'bg-primary/10 border-primary'
: 'hover:bg-accent'}"
>
<Button
variant="ghost"
class="flex-1 justify-start h-auto p-2"
onclick={() => selectRole(role)}
>
<div class="flex flex-col items-start gap-1 w-full">
<div class="flex items-center gap-2 w-full">
<Shield class="h-4 w-4 flex-shrink-0" />
<span class="font-medium text-sm truncate">{role.name}</span>
</div>
{#if !role.is_active}
<Badge variant="secondary" class="text-xs">Inactivo</Badge>
{/if}
</div>
</Button>
<div class="flex gap-1">
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
onclick={() => openEditRoleDialog(role)}
>
<Pencil class="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
onclick={() => handleDeleteRole(role)}
>
<Trash2 class="h-3 w-3" />
</Button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Permissions List -->
<div class="lg:col-span-2">
{#if selectedRole}
<div class="border rounded-lg">
<div class="p-4 border-b flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold flex items-center gap-2">
<Key class="h-5 w-5" />
Permisos de "{selectedRole.name}"
</h2>
<p class="text-sm text-muted-foreground">
{rolePermissions.length} permisos asignados
</p>
</div>
<Button onclick={openAddDialog} disabled={availablePermissions.length === 0}>
<Plus class="mr-2 h-4 w-4" />
Agregar Permisos
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Módulo</TableHead>
<TableHead>Acción</TableHead>
<TableHead>Descripción</TableHead>
<TableHead class="text-right">Acciones</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#if loadingPermissions}
<TableRow>
<TableCell colspan={4} class="text-center py-8">
Cargando permisos...
</TableCell>
</TableRow>
{:else if rolePermissions.length === 0}
<TableRow>
<TableCell colspan={4} class="text-center py-8">
Este rol no tiene permisos asignados
</TableCell>
</TableRow>
{:else}
{#each rolePermissions as rp (rp.id)}
<TableRow>
<TableCell>
<Badge variant="outline">{rp.permission?.module}</Badge>
</TableCell>
<TableCell>{rp.permission?.action}</TableCell>
<TableCell class="text-muted-foreground">
{rp.permission?.description || '-'}
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
onclick={() => handleRemovePermission(rp.permission_id)}
>
<Trash2 class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
{/each}
{/if}
</TableBody>
</Table>
</div>
{:else}
<div class="border rounded-lg p-12 text-center">
<Shield class="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p class="text-muted-foreground">Selecciona un rol para ver sus permisos</p>
</div>
{/if}
</div>
</div>
</div>
<!-- Add Permissions Dialog -->
<Dialog.Root bind:open={showPermissionsDialog}>
<Dialog.Content class="max-w-2xl max-h-[80vh]">
<Dialog.Header>
<Dialog.Title>Agregar Permisos a "{selectedRole?.name}"</Dialog.Title>
</Dialog.Header>
<div class="overflow-y-auto max-h-96 py-4">
<div class="px-4 pb-2">
<Input
type="text"
placeholder="Buscar permisos..."
bind:value={permissionSearchQuery}
oninput={() => updatePermissionsByModule()}
class="w-full"
/>
</div>
{#if availablePermissions.length === 0}
<p class="text-center text-muted-foreground py-8">
No hay permisos disponibles para asignar
</p>
{:else}
<div class="space-y-4">
{#each [...permissionsByModule] as [module, permissions] (module)}
<div class="border rounded-lg p-4">
<h3 class="font-semibold mb-3 flex items-center gap-2">
<Badge>{module}</Badge>
<span class="text-xs text-muted-foreground">({permissions.length} permisos, {new Set(permissions.map(p => p.id)).size} únicos)</span>
</h3>
<div class="space-y-2">
{#each permissions as permission (permission.id)}
<div class="flex items-start space-x-3">
<Checkbox
id="perm-{permission.id}"
checked={selectedPermissionIds.includes(permission.id)}
onCheckedChange={() => togglePermission(permission.id)}
/>
<div class="flex-1">
<Label
for="perm-{permission.id}"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{permission.description || permission.code}
</Label>
<div class="flex items-center gap-2 mt-1">
<code class="text-xs bg-muted px-1.5 py-0.5 rounded">{permission.code}</code>
</div>
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showPermissionsDialog = false)}>Cancelar</Button>
<Button onclick={handleAssignPermissions} disabled={selectedPermissionIds.length === 0}>
Asignar {selectedPermissionIds.length} permisos
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Create/Edit Role Dialog -->
<Dialog.Root bind:open={showRoleDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>
{editingRole ? 'Editar Rol' : 'Nuevo Rol'}
</Dialog.Title>
</Dialog.Header>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="name">Nombre</Label>
<Input id="name" bind:value={roleFormData.name} placeholder="Administrador" />
</div>
<div class="grid gap-2">
<Label for="code">Código</Label>
<Input id="code" bind:value={roleFormData.code} placeholder="ADMIN" />
</div>
<div class="grid gap-2">
<Label for="description">Descripción</Label>
<Textarea
id="description"
bind:value={roleFormData.description}
placeholder="Rol con acceso completo al sistema"
/>
</div>
<div class="flex items-center space-x-2">
<Switch id="is_active" bind:checked={roleFormData.is_active} />
<Label for="is_active">Activo</Label>
</div>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (showRoleDialog = false)}>Cancelar</Button>
<Button onclick={handleRoleSubmit}>
{editingRole ? 'Actualizar' : 'Crear'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1,5 +1,5 @@
import type { PageServerLoad } from './$types';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
import { getAuthTokens, authenticatedFetch, handleApiResponse } from '$lib/server/api';
export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
// Esperar a que el layout padre valide/refresque el token
@@ -63,16 +63,13 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
fetch
);
if (!response.ok) {
const errorText = await response.text();
console.error('📊 [Clients&Providers] API Error:', {
status: response.status,
statusText: response.statusText,
error: errorText
});
const result = await handleApiResponse(response);
if (result.error) {
console.error('📊 [Clients&Providers] API Error:', result.error);
return {
error: `Error ${response.status}: ${response.statusText}`,
error: result.error,
items: [],
total: 0,
page: page,
@@ -82,7 +79,7 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
};
}
const data = await response.json();
const data = result.data;
return {
items: data.items || [],

View File

@@ -12,6 +12,7 @@
import type { PageData } from './$types';
import { browser } from '$app/environment';
import { companyStore } from '$lib/stores/company.svelte';
import type { ApiError } from '$lib/utils/error-handler';
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
@@ -84,7 +85,7 @@
let totalItems = $state(data.total || 0);
let loading = $state(false);
let hasMore = $derived(allItems.length < totalItems);
let error = $state<string | null>(data.error || null);
let error = $state<string | ApiError | null>(data.error || null);
async function loadMore() {
if (loading || !hasMore || !companyStore.activeCompany) return;
@@ -205,7 +206,9 @@
<Card.Root class="border-destructive">
<Card.Header>
<Card.Title class="text-destructive">Error</Card.Title>
<Card.Description>{error}</Card.Description>
<Card.Description>
{typeof error === 'string' ? error : error.detail}
</Card.Description>
</Card.Header>
</Card.Root>
{/if}

View File

@@ -11,7 +11,7 @@
import type { PageData } from './$types';
import { browser } from '$app/environment';
import { companyStore } from '$lib/stores/company.svelte';
import { Plus, Filter, Trash2, RefreshCw } from 'lucide-svelte';
import { Plus, RefreshCw } from 'lucide-svelte';
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();

File diff suppressed because it is too large Load Diff