feat(auth): enhance authentication service with error handling and session management

- Added methods to normalize permissions and roles from API responses.
- Implemented a centralized error handling method for authentication errors.
- Updated API endpoints for login, registration, and user profile management.
- Introduced session refresh functionality to retrieve user roles and permissions.

feat(catalog): improve companies and units management with permissions and filters

- Integrated permission checks for creating, updating, and deleting companies.
- Added user role and permission checks to the Companies component.
- Enhanced the Units component with search and status filters.
- Refactored unit creation and update logic to handle validation errors.

fix(catalog): update unit measure services and mapping logic

- Improved API service methods for fetching, creating, and updating units of measure.
- Added mapping functions to convert API responses to internal data structures.
- Enhanced error handling in unit measure services.

chore(auth): refactor authentication storage utilities

- Created utility functions for managing authentication tokens and user data in local storage.
- Updated API interceptor to use new storage utility functions for session management.

style: clean up code formatting and improve readability across components and services
This commit is contained in:
edgar.mendez 2026-03-21 20:04:40 -06:00
parent 93a2527e60
commit 1189b7b02e
15 changed files with 823 additions and 158 deletions

View File

@ -1,15 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { useAuth } from '../../modules/auth/composables/useAuth';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const { hasPermission } = useAuth();
interface MenuItem { interface MenuItem {
label: string; label: string;
icon: string; icon: string;
to?: string; to?: string;
items?: MenuItem[]; items?: MenuItem[];
permission?: string | string[];
} }
const menuItems = ref<MenuItem[]>([ const menuItems = ref<MenuItem[]>([
@ -26,7 +29,18 @@ const menuItems = ref<MenuItem[]>([
{ label: 'Clasificaciones Comerciales', icon: 'pi pi-tags', to: '/catalog/classifications-comercial' }, { label: 'Clasificaciones Comerciales', icon: 'pi pi-tags', to: '/catalog/classifications-comercial' },
{ label: 'Proveedores', icon: 'pi pi-briefcase', to: '/catalog/suppliers' }, { label: 'Proveedores', icon: 'pi pi-briefcase', to: '/catalog/suppliers' },
{ label: 'Documentos del Modelo', icon: 'pi pi-file', to: '/catalog/model-documents' }, { label: 'Documentos del Modelo', icon: 'pi pi-file', to: '/catalog/model-documents' },
{ label: 'Empresas', icon: 'pi pi-building', to: '/catalog/companies' } {
label: 'Empresas',
icon: 'pi pi-building',
to: '/catalog/companies',
permission: [
'companies.index',
'companies.show',
'companies.store',
'companies.update',
'companies.destroy',
],
}
] ]
}, },
{ {
@ -104,6 +118,36 @@ const menuItems = ref<MenuItem[]>([
const sidebarVisible = ref(true); const sidebarVisible = ref(true);
const openItems = ref<string[]>([]); const openItems = ref<string[]>([]);
const canAccessItem = (item: MenuItem): boolean => {
if (!item.permission) {
return false;
}
return hasPermission(item.permission);
};
const filterMenuItems = (items: MenuItem[]): MenuItem[] => {
return items
.map((item) => {
if (item.items && item.items.length > 0) {
const children = filterMenuItems(item.items);
if (children.length === 0) {
return null;
}
return {
...item,
items: children,
};
}
return canAccessItem(item) ? item : null;
})
.filter((item): item is MenuItem => item !== null);
};
const visibleMenuItems = computed(() => filterMenuItems(menuItems.value));
const toggleSidebar = () => { const toggleSidebar = () => {
sidebarVisible.value = !sidebarVisible.value; sidebarVisible.value = !sidebarVisible.value;
}; };
@ -139,7 +183,7 @@ const isRouteActive = (to: string | undefined) => {
// SOLO marcar activo si la ruta hija NO está explícitamente en el menú // SOLO marcar activo si la ruta hija NO está explícitamente en el menú
if (route.path.startsWith(to + '/')) { if (route.path.startsWith(to + '/')) {
// Verificar si la ruta actual está definida como un item del menú // Verificar si la ruta actual está definida como un item del menú
const isExplicitRoute = menuItems.value.some(item => { const isExplicitRoute = visibleMenuItems.value.some(item => {
if (item.items) { if (item.items) {
return item.items.some(subItem => subItem.to === route.path); return item.items.some(subItem => subItem.to === route.path);
} }
@ -202,8 +246,15 @@ defineExpose({ toggleSidebar });
<!-- Navigation Menu --> <!-- Navigation Menu -->
<nav class="flex-1 overflow-y-auto p-3"> <nav class="flex-1 overflow-y-auto p-3">
<div v-if="visibleMenuItems.length === 0" class="px-3 py-6 text-center">
<i class="pi pi-lock text-2xl text-surface-400 dark:text-surface-500"></i>
<p v-if="sidebarVisible" class="mt-3 text-sm text-surface-500 dark:text-surface-400">
No tienes permisos para ver módulos.
</p>
</div>
<ul class="space-y-1"> <ul class="space-y-1">
<li v-for="item in menuItems" :key="item.label"> <li v-for="item in visibleMenuItems" :key="item.label">
<!-- Item sin subitems --> <!-- Item sin subitems -->
<a v-if="!item.items" :href="item.to" @click.prevent="item.to && navigateTo(item.to)" :class="[ <a v-if="!item.items" :href="item.to" @click.prevent="item.to && navigateTo(item.to)" :class="[
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all cursor-pointer', 'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all cursor-pointer',

View File

@ -34,10 +34,6 @@ const MyPreset = definePreset(Aura, {
const app = createApp(App); const app = createApp(App);
const pinia = createPinia(); const pinia = createPinia();
// Inicializar autenticación desde localStorage
const { initAuth } = useAuth();
initAuth();
app.use(pinia); app.use(pinia);
app.use(router); app.use(router);
app.use(ConfirmationService); app.use(ConfirmationService);
@ -53,4 +49,10 @@ app.use(PrimeVue, {
app.directive("styleclass", StyleClass); app.directive("styleclass", StyleClass);
app.mount("#app"); const bootstrap = async () => {
const { initAuth } = useAuth();
await initAuth();
app.mount('#app');
};
bootstrap();

View File

@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useAuth } from '../composables/useAuth'; import { useAuth } from '../composables/useAuth';
import { useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
const router = useRouter(); const router = useRouter();
const route = useRoute();
const { login, isLoading } = useAuth(); const { login, isLoading } = useAuth();
const email = ref(''); const email = ref('');
@ -24,7 +25,9 @@ const handleLogin = async () => {
}); });
if (result.success) { if (result.success) {
router.push('/'); const requestedRedirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/';
const redirect = requestedRedirect.startsWith('/') ? requestedRedirect : '/';
router.push(redirect);
} else { } else {
errorMessage.value = result.error || 'Error al iniciar sesión'; errorMessage.value = result.error || 'Error al iniciar sesión';
} }

View File

@ -1,5 +1,13 @@
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import authService from '../services/authService'; import authService from '../services/authService';
import {
clearStoredAuthSession,
persistAuthSession,
readStoredPermissions,
readStoredRoles,
readStoredToken,
readStoredUser,
} from '../utils/authStorage';
export interface Role { export interface Role {
id: number; id: number;
@ -51,23 +59,60 @@ export interface LoginCredentials {
// Estado global de autenticación // Estado global de autenticación
const user = ref<User | null>(null); const user = ref<User | null>(null);
const token = ref<string | null>(null); const token = ref<string | null>(null);
const roles = ref<Role[]>([]);
const permissions = ref<string[]>([]);
const isLoading = ref(false); const isLoading = ref(false);
const isInitialized = ref(false);
let unauthorizedListenerRegistered = false;
const normalizeTarget = (target: string | string[]): string[] =>
Array.isArray(target) ? target : [target];
const clearSession = () => {
user.value = null;
token.value = null;
roles.value = [];
permissions.value = [];
clearStoredAuthSession();
};
const hasRole = (target: string | string[]): boolean => {
const roleNames = roles.value.map((role) => role.name);
return normalizeTarget(target).some((roleName) => roleNames.includes(roleName));
};
const hasPermission = (target: string | string[]): boolean => {
return normalizeTarget(target).some((permissionName) => permissions.value.includes(permissionName));
};
// Inicializar desde localStorage // Inicializar desde localStorage
const initAuth = () => { const initAuth = async () => {
try { try {
const storedToken = localStorage.getItem('auth_token'); const storedToken = readStoredToken();
const storedUser = localStorage.getItem('auth_user'); const storedUser = readStoredUser();
const storedRoles = readStoredRoles();
const storedPermissions = readStoredPermissions();
if (storedToken && storedUser && storedUser !== 'undefined') { if (!storedToken || !storedUser) {
token.value = storedToken; clearSession();
user.value = JSON.parse(storedUser); return;
} }
token.value = storedToken;
user.value = storedUser;
roles.value = storedRoles;
permissions.value = storedPermissions;
const sessionData = await authService.refreshSession();
user.value = sessionData.user;
roles.value = sessionData.roles;
permissions.value = sessionData.permissions;
persistAuthSession(storedToken, sessionData.user, sessionData.roles, sessionData.permissions);
} catch (error) { } catch (error) {
console.error('Error al inicializar autenticación:', error); console.error('Error al inicializar autenticación:', error);
// Limpiar localStorage si hay datos corruptos clearSession();
localStorage.removeItem('auth_token'); } finally {
localStorage.removeItem('auth_user'); isInitialized.value = true;
} }
}; };
@ -85,10 +130,18 @@ const login = async (credentials: LoginCredentials): Promise<{ success: boolean;
// Guardar en estado // Guardar en estado
user.value = response.user; user.value = response.user;
token.value = response.token; token.value = response.token;
roles.value = [];
permissions.value = [];
persistAuthSession(response.token, response.user, [], []);
const sessionData = await authService.refreshSession();
user.value = sessionData.user;
roles.value = sessionData.roles;
permissions.value = sessionData.permissions;
// Persistir en localStorage // Persistir en localStorage
localStorage.setItem('auth_token', response.token); persistAuthSession(response.token, sessionData.user, sessionData.roles, sessionData.permissions);
localStorage.setItem('auth_user', JSON.stringify(response.user));
return { success: true }; return { success: true };
} catch (error: any) { } catch (error: any) {
@ -105,10 +158,7 @@ const login = async (credentials: LoginCredentials): Promise<{ success: boolean;
// Función de logout // Función de logout
const logout = async () => { const logout = async () => {
// Limpiar estado local inmediatamente para reactividad // Limpiar estado local inmediatamente para reactividad
user.value = null; clearSession();
token.value = null;
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_user');
try { try {
// Notificar al backend (en segundo plano) // Notificar al backend (en segundo plano)
@ -119,15 +169,28 @@ const logout = async () => {
} }
}; };
if (typeof window !== 'undefined' && !unauthorizedListenerRegistered) {
unauthorizedListenerRegistered = true;
window.addEventListener('auth:unauthorized', () => {
clearSession();
});
}
// Composable para usar en componentes // Composable para usar en componentes
export const useAuth = () => { export const useAuth = () => {
return { return {
user, user,
token, token,
roles,
permissions,
isLoading, isLoading,
isInitialized,
isAuthenticated, isAuthenticated,
hasRole,
hasPermission,
login, login,
logout, logout,
initAuth initAuth,
clearSession,
}; };
}; };

View File

@ -17,7 +17,87 @@ export interface RegisterData {
password_confirmation: string; password_confirmation: string;
} }
interface SessionRefreshData {
user: User;
roles: Role[];
permissions: string[];
}
class AuthService { class AuthService {
private extractArrayFromResponse(payload: unknown): unknown[] {
if (Array.isArray(payload)) {
return payload;
}
if (!payload || typeof payload !== 'object') {
return [];
}
const root = payload as Record<string, unknown>;
if (Array.isArray(root.data)) {
return root.data;
}
if (root.data && typeof root.data === 'object') {
const nested = root.data as Record<string, unknown>;
if (Array.isArray(nested.permissions)) {
return nested.permissions;
}
if (Array.isArray(nested.roles)) {
return nested.roles;
}
}
return [];
}
private normalizePermissions(payload: unknown): string[] {
const items = this.extractArrayFromResponse(payload);
return items
.map((item) => {
if (typeof item === 'string') {
return item;
}
if (item && typeof item === 'object' && typeof (item as Record<string, unknown>).name === 'string') {
return (item as Record<string, string>).name;
}
return null;
})
.filter((permission): permission is string => Boolean(permission));
}
private normalizeRoles(payload: unknown): Role[] {
const items = this.extractArrayFromResponse(payload);
return items.filter((item): item is Role => {
if (!item || typeof item !== 'object') {
return false;
}
const role = item as Partial<Role>;
return typeof role.name === 'string';
});
}
private resolveAuthError(error: any, fallback = 'Error en autenticación'): never {
if (error.response?.status === 422 && error.response?.data?.errors) {
const firstError = Object.values(error.response.data.errors)?.[0] as string[] | undefined;
throw new Error(firstError?.[0] || error.response.data.message || fallback);
}
if (error.response?.status === 429) {
throw new Error('Demasiados intentos de inicio de sesión. Intenta nuevamente en un minuto.');
}
throw new Error(error.response?.data?.message || fallback);
}
/** /**
* Iniciar sesión * Iniciar sesión
*/ */
@ -34,17 +114,7 @@ class AuthService {
token: response.data.data.token token: response.data.data.token
}; };
} catch (error: any) { } catch (error: any) {
console.error('Error en login:', error); this.resolveAuthError(error, 'Error al iniciar sesión');
// Manejar errores de validación (422)
if (error.response?.status === 422 && error.response?.data?.errors) {
const errors = error.response.data.errors;
const firstError = Object.values(errors)[0] as string[];
throw new Error(firstError[0] || error.response.data.message);
}
// Manejar otros errores
throw new Error(error.response?.data?.message || 'Error al iniciar sesión');
} }
} }
@ -53,10 +123,10 @@ class AuthService {
*/ */
async register(data: RegisterData): Promise<LoginResponse> { async register(data: RegisterData): Promise<LoginResponse> {
try { try {
const response = await api.post<LoginResponse>('/auth/register', data); const response = await api.post<LoginResponse>('/api/auth/register', data);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
throw new Error(error.response?.data?.message || 'Error al registrar usuario'); this.resolveAuthError(error, 'Error al registrar usuario');
} }
} }
@ -65,18 +135,7 @@ class AuthService {
*/ */
async logout(): Promise<void> { async logout(): Promise<void> {
try { try {
const response = await api.post<{ await api.post('/api/auth/logout');
status: string;
data: {
is_revoked: boolean;
};
}>('/api/auth/logout');
console.log('Respuesta logout:', response.data);
if (response.data.status === 'success' && response.data.data.is_revoked) {
console.log('Token revocado exitosamente');
}
} catch (error: any) { } catch (error: any) {
console.error('Error al cerrar sesión:', error); console.error('Error al cerrar sesión:', error);
// No lanzar error para que el logout local continúe aunque falle el backend // No lanzar error para que el logout local continúe aunque falle el backend
@ -88,10 +147,46 @@ class AuthService {
*/ */
async getCurrentUser(): Promise<User> { async getCurrentUser(): Promise<User> {
try { try {
const response = await api.get<User>('/auth/me'); const response = await api.get<User>('/api/user');
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
throw new Error(error.response?.data?.message || 'Error al obtener usuario'); this.resolveAuthError(error, 'Error al obtener usuario');
}
}
async getUserRoles(): Promise<Role[]> {
try {
const response = await api.get('/api/user/roles');
return this.normalizeRoles(response.data);
} catch (error: any) {
this.resolveAuthError(error, 'Error al obtener roles del usuario');
}
}
async getUserPermissions(): Promise<string[]> {
try {
const response = await api.get('/api/user/permissions');
return this.normalizePermissions(response.data);
} catch (error: any) {
this.resolveAuthError(error, 'Error al obtener permisos del usuario');
}
}
async refreshSession(): Promise<SessionRefreshData> {
try {
const [currentUser, roles, permissions] = await Promise.all([
this.getCurrentUser(),
this.getUserRoles(),
this.getUserPermissions(),
]);
return {
user: currentUser,
roles,
permissions,
};
} catch (error: any) {
this.resolveAuthError(error, 'Error al sincronizar sesión');
} }
} }
@ -100,10 +195,10 @@ class AuthService {
*/ */
async updateProfile(data: Partial<User>): Promise<User> { async updateProfile(data: Partial<User>): Promise<User> {
try { try {
const response = await api.put<User>('/auth/profile', data); const response = await api.put<User>('/api/auth/profile', data);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
throw new Error(error.response?.data?.message || 'Error al actualizar perfil'); this.resolveAuthError(error, 'Error al actualizar perfil');
} }
} }
@ -112,12 +207,12 @@ class AuthService {
*/ */
async changePassword(currentPassword: string, newPassword: string): Promise<void> { async changePassword(currentPassword: string, newPassword: string): Promise<void> {
try { try {
await api.post('/auth/change-password', { await api.post('/api/auth/change-password', {
current_password: currentPassword, current_password: currentPassword,
new_password: newPassword new_password: newPassword
}); });
} catch (error: any) { } catch (error: any) {
throw new Error(error.response?.data?.message || 'Error al cambiar contraseña'); this.resolveAuthError(error, 'Error al cambiar contraseña');
} }
} }
@ -126,9 +221,9 @@ class AuthService {
*/ */
async forgotPassword(email: string): Promise<void> { async forgotPassword(email: string): Promise<void> {
try { try {
await api.post('/auth/forgot-password', { email }); await api.post('/api/auth/forgot-password', { email });
} catch (error: any) { } catch (error: any) {
throw new Error(error.response?.data?.message || 'Error al solicitar recuperación'); this.resolveAuthError(error, 'Error al solicitar recuperación');
} }
} }
@ -137,9 +232,9 @@ class AuthService {
*/ */
async resetPassword(token: string, password: string): Promise<void> { async resetPassword(token: string, password: string): Promise<void> {
try { try {
await api.post('/auth/reset-password', { token, password }); await api.post('/api/auth/reset-password', { token, password });
} catch (error: any) { } catch (error: any) {
throw new Error(error.response?.data?.message || 'Error al resetear contraseña'); this.resolveAuthError(error, 'Error al resetear contraseña');
} }
} }
} }

View File

@ -0,0 +1,66 @@
import type { Role, User } from '../composables/useAuth';
export const AUTH_TOKEN_KEY = 'auth_token';
export const AUTH_USER_KEY = 'auth_user';
export const AUTH_ROLES_KEY = 'auth_roles';
export const AUTH_PERMISSIONS_KEY = 'auth_permissions';
export const readStoredToken = (): string | null => localStorage.getItem(AUTH_TOKEN_KEY);
export const readStoredUser = (): User | null => {
const rawUser = localStorage.getItem(AUTH_USER_KEY);
if (!rawUser || rawUser === 'undefined') {
return null;
}
try {
return JSON.parse(rawUser) as User;
} catch {
return null;
}
};
export const readStoredRoles = (): Role[] => {
const rawRoles = localStorage.getItem(AUTH_ROLES_KEY);
if (!rawRoles || rawRoles === 'undefined') {
return [];
}
try {
const parsed = JSON.parse(rawRoles) as Role[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
export const readStoredPermissions = (): string[] => {
const rawPermissions = localStorage.getItem(AUTH_PERMISSIONS_KEY);
if (!rawPermissions || rawPermissions === 'undefined') {
return [];
}
try {
const parsed = JSON.parse(rawPermissions) as string[];
return Array.isArray(parsed) ? parsed.filter((permission) => typeof permission === 'string') : [];
} catch {
return [];
}
};
export const persistAuthSession = (token: string, user: User, roles: Role[] = [], permissions: string[] = []) => {
localStorage.setItem(AUTH_TOKEN_KEY, token);
localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user));
localStorage.setItem(AUTH_ROLES_KEY, JSON.stringify(roles));
localStorage.setItem(AUTH_PERMISSIONS_KEY, JSON.stringify(permissions));
};
export const clearStoredAuthSession = () => {
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(AUTH_USER_KEY);
localStorage.removeItem(AUTH_ROLES_KEY);
localStorage.removeItem(AUTH_PERMISSIONS_KEY);
};

View File

@ -9,6 +9,7 @@
<p class="text-slate-500 dark:text-slate-400">Administra y monitorea todas las entidades legales registradas en el sistema.</p> <p class="text-slate-500 dark:text-slate-400">Administra y monitorea todas las entidades legales registradas en el sistema.</p>
</div> </div>
<Button <Button
v-if="canCreateCompany"
label="Registrar Nueva Empresa" label="Registrar Nueva Empresa"
icon="pi pi-plus" icon="pi pi-plus"
@click="openCreateDialog" @click="openCreateDialog"
@ -17,7 +18,7 @@
</div> </div>
<!-- Table Section --> <!-- Table Section -->
<Card> <Card v-if="canViewCompanies">
<template #content> <template #content>
<DataTable <DataTable
:value="companies" :value="companies"
@ -70,6 +71,7 @@
<template #body="{ data }"> <template #body="{ data }">
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
v-if="canUpdateCompany"
icon="pi pi-pencil" icon="pi pi-pencil"
class="p-button-rounded p-button-text p-button-sm" class="p-button-rounded p-button-text p-button-sm"
@click="editCompany(data)" @click="editCompany(data)"
@ -82,6 +84,7 @@
v-tooltip.top="'Ver detalles'" v-tooltip.top="'Ver detalles'"
/> />
<Button <Button
v-if="canDeleteCompany"
icon="pi pi-trash" icon="pi pi-trash"
class="p-button-rounded p-button-text p-button-sm p-button-danger" class="p-button-rounded p-button-text p-button-sm p-button-danger"
@click="confirmDelete(data)" @click="confirmDelete(data)"
@ -101,6 +104,15 @@
</template> </template>
</Card> </Card>
<Card v-else>
<template #content>
<div class="text-center py-8">
<i class="pi pi-lock text-4xl text-slate-300 mb-3"></i>
<p class="text-slate-500">No tienes permisos para ver el listado de empresas.</p>
</div>
</template>
</Card>
<!-- Form Dialog --> <!-- Form Dialog -->
<CompaniesForm <CompaniesForm
ref="companiesFormRef" ref="companiesFormRef"
@ -125,14 +137,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { computed, ref, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import CompaniesForm from './CompaniesForm.vue'; import CompaniesForm from './CompaniesForm.vue';
import { companiesService } from './companies.service'; import { companiesService } from './companies.service';
import type { Company, TenantFormData } from './companies.types'; import type { Company, TenantFormData } from './companies.types';
import { useAuth } from '@/modules/auth/composables/useAuth';
// Toast // Toast
const toast = useToast(); const toast = useToast();
const { hasPermission } = useAuth();
// Refs // Refs
const companiesFormRef = ref<InstanceType<typeof CompaniesForm> | null>(null); const companiesFormRef = ref<InstanceType<typeof CompaniesForm> | null>(null);
@ -149,12 +163,29 @@ const companyToDelete = ref<Company | null>(null);
const currentPage = ref(1); const currentPage = ref(1);
const totalRecords = ref(0); const totalRecords = ref(0);
const perPage = ref(10); const perPage = ref(10);
const canViewCompanies = computed(() =>
hasPermission([
'companies.index',
'companies.show',
'companies.store',
'companies.update',
'companies.destroy',
])
);
const canCreateCompany = computed(() => hasPermission('companies.store'));
const canUpdateCompany = computed(() => hasPermission('companies.update'));
const canDeleteCompany = computed(() => hasPermission('companies.destroy'));
// Search filters // Search filters
// Methods // Methods
const loadCompanies = async (page = currentPage.value) => { const loadCompanies = async (page = currentPage.value) => {
if (!canViewCompanies.value) {
companies.value = [];
return;
}
loading.value = true; loading.value = true;
try { try {
const response = await companiesService.getAll(page); const response = await companiesService.getAll(page);
@ -176,11 +207,19 @@ const onPageChange = (event: any) => {
}; };
const openCreateDialog = () => { const openCreateDialog = () => {
if (!canCreateCompany.value) {
return;
}
selectedCompany.value = undefined; selectedCompany.value = undefined;
dialogVisible.value = true; dialogVisible.value = true;
}; };
const editCompany = (company: Company) => { const editCompany = (company: Company) => {
if (!canUpdateCompany.value) {
return;
}
selectedCompany.value = { ...company }; selectedCompany.value = { ...company };
dialogVisible.value = true; dialogVisible.value = true;
}; // No change needed assigns a valid Company }; // No change needed assigns a valid Company
@ -191,6 +230,10 @@ const viewCompany = (company: Company) => {
}; };
const confirmDelete = (company: Company) => { const confirmDelete = (company: Company) => {
if (!canDeleteCompany.value) {
return;
}
companyToDelete.value = company; companyToDelete.value = company;
deleteDialogVisible.value = true; deleteDialogVisible.value = true;
}; };
@ -228,6 +271,28 @@ const handleSave = async (companyData: TenantFormData) => {
try { try {
const isEditMode = Boolean(selectedCompany.value?.id); const isEditMode = Boolean(selectedCompany.value?.id);
if (isEditMode && !canUpdateCompany.value) {
toast.add({
severity: 'warn',
summary: 'Sin permisos',
detail: 'No tienes permiso para actualizar empresas.',
life: 4000,
});
companiesFormRef.value?.resetSubmitting();
return false;
}
if (!isEditMode && !canCreateCompany.value) {
toast.add({
severity: 'warn',
summary: 'Sin permisos',
detail: 'No tienes permiso para crear empresas.',
life: 4000,
});
companiesFormRef.value?.resetSubmitting();
return false;
}
if (isEditMode && selectedCompany.value?.id) { if (isEditMode && selectedCompany.value?.id) {
await companiesService.update(selectedCompany.value.id, companyData); await companiesService.update(selectedCompany.value.id, companyData);
toast.add({ toast.add({
@ -287,7 +352,9 @@ const getInitials = (name: string): string => {
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {
if (canViewCompanies.value) {
loadCompanies(); loadCompanies();
}
}); });
</script> </script>

View File

@ -12,6 +12,8 @@ import Tag from 'primevue/tag';
import Toast from 'primevue/toast'; import Toast from 'primevue/toast';
import ConfirmDialog from 'primevue/confirmdialog'; import ConfirmDialog from 'primevue/confirmdialog';
import ProgressSpinner from 'primevue/progressspinner'; import ProgressSpinner from 'primevue/progressspinner';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import { useUnitOfMeasureStore } from '../../stores/unitOfMeasureStore'; import { useUnitOfMeasureStore } from '../../stores/unitOfMeasureStore';
import UnitsForm from './UnitsForm.vue'; import UnitsForm from './UnitsForm.vue';
import type { UnitOfMeasure, CreateUnitOfMeasureData } from '../../types/unit-measure.interfaces'; import type { UnitOfMeasure, CreateUnitOfMeasureData } from '../../types/unit-measure.interfaces';
@ -36,18 +38,40 @@ const home = ref({
const showDialog = ref(false); const showDialog = ref(false);
const isEditing = ref(false); const isEditing = ref(false);
const selectedUnit = ref<UnitOfMeasure | null>(null); const selectedUnit = ref<UnitOfMeasure | null>(null);
const unitsFormRef = ref<InstanceType<typeof UnitsForm> | null>(null);
const search = ref('');
const statusFilter = ref<'all' | 'active' | 'inactive'>('all');
// Computed // Computed
const units = computed(() => unitStore.units); const units = computed(() => unitStore.units);
const loading = computed(() => unitStore.loading); const loading = computed(() => unitStore.loading);
const statusFilterOptions = [
{ label: 'Todas', value: 'all' },
{ label: 'Activas', value: 'active' },
{ label: 'Inactivas', value: 'inactive' },
];
const getStatusConfig = (isActive: number) => { const getStatusConfig = (isActive: boolean) => {
return isActive === 1 return isActive
? { label: 'Activa', severity: 'success' } ? { label: 'Activa', severity: 'success' }
: { label: 'Inactiva', severity: 'secondary' }; : { label: 'Inactiva', severity: 'secondary' };
}; };
// Methods // Methods
const buildFilters = () => ({
paginate: true,
per_page: 25,
search: search.value.trim(),
is_active: statusFilter.value === 'all' ? undefined : statusFilter.value === 'active',
});
const loadUnits = async (force = false) => {
await unitStore.fetchUnits({
force,
...buildFilters(),
});
};
const openCreateDialog = () => { const openCreateDialog = () => {
isEditing.value = false; isEditing.value = false;
selectedUnit.value = null; selectedUnit.value = null;
@ -60,33 +84,67 @@ const openEditDialog = (unit: UnitOfMeasure) => {
showDialog.value = true; showDialog.value = true;
}; };
const handleSaveUnit = async (data: CreateUnitOfMeasureData) => { const handleCreateUnit = async (data: CreateUnitOfMeasureData) => {
try {
if (isEditing.value && selectedUnit.value) {
await unitStore.updateUnit(selectedUnit.value.id, data);
toast.add({
severity: 'success',
summary: 'Actualización Exitosa',
detail: 'La unidad de medida ha sido actualizada correctamente.',
life: 3000
});
} else {
await unitStore.createUnit(data); await unitStore.createUnit(data);
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: 'Creación Exitosa', summary: 'Creación Exitosa',
detail: 'La unidad de medida ha sido creada correctamente.', detail: 'La unidad de medida ha sido creada correctamente.',
life: 3000 life: 3000,
}); });
};
const handleUpdateUnit = async (id: number, data: CreateUnitOfMeasureData) => {
await unitStore.updateUnit(id, data);
toast.add({
severity: 'success',
summary: 'Actualización Exitosa',
detail: 'La unidad de medida ha sido actualizada correctamente.',
life: 3000,
});
};
const handleSaveUnit = async (data: CreateUnitOfMeasureData) => {
try {
if (isEditing.value && selectedUnit.value) {
await handleUpdateUnit(selectedUnit.value.id, data);
} else {
await handleCreateUnit(data);
} }
showDialog.value = false; showDialog.value = false;
selectedUnit.value = null;
unitsFormRef.value?.resetSubmitting();
} catch (error) { } catch (error) {
console.error('Error saving unit:', error); console.error('Error saving unit:', error);
const requestError = error as {
response?: {
status?: number;
data?: {
errors?: Record<string, string[]>;
message?: string;
};
};
};
if (requestError.response?.status === 422 && requestError.response.data?.errors) {
unitsFormRef.value?.setValidationErrors(requestError.response.data.errors);
toast.add({
severity: 'warn',
summary: 'Errores de validación',
detail: 'Corrige los campos marcados para continuar.',
life: 4000,
});
return;
}
unitsFormRef.value?.resetSubmitting();
toast.add({ toast.add({
severity: 'error', severity: 'error',
summary: 'Error', summary: 'Error',
detail: 'No se pudo guardar la unidad de medida. Por favor, intenta nuevamente.', detail: requestError.response?.data?.message || 'No se pudo guardar la unidad de medida. Por favor, intenta nuevamente.',
life: 3000 life: 3000,
}); });
} }
}; };
@ -106,11 +164,12 @@ const confirmDelete = (unit: UnitOfMeasure) => {
const deleteUnit = async (id: number) => { const deleteUnit = async (id: number) => {
try { try {
await unitStore.deleteUnit(id); await unitStore.deleteUnit(id);
await loadUnits(true);
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: 'Eliminación Exitosa', summary: 'Eliminación Exitosa',
detail: 'La unidad de medida ha sido eliminada correctamente.', detail: 'La unidad de medida ha sido eliminada correctamente.',
life: 3000 life: 3000,
}); });
} catch (error) { } catch (error) {
console.error('Error deleting unit:', error); console.error('Error deleting unit:', error);
@ -118,14 +177,24 @@ const deleteUnit = async (id: number) => {
severity: 'error', severity: 'error',
summary: 'Error', summary: 'Error',
detail: 'No se pudo eliminar la unidad de medida. Puede estar en uso.', detail: 'No se pudo eliminar la unidad de medida. Puede estar en uso.',
life: 3000 life: 3000,
}); });
} }
}; };
const applyFilters = async () => {
await loadUnits(true);
};
const clearFilters = async () => {
search.value = '';
statusFilter.value = 'all';
await loadUnits(true);
};
// Lifecycle // Lifecycle
onMounted(async () => { onMounted(async () => {
await unitStore.fetchUnits(); await loadUnits();
}); });
</script> </script>
@ -169,6 +238,42 @@ onMounted(async () => {
<!-- Main Card --> <!-- Main Card -->
<Card> <Card>
<template #content> <template #content>
<div class="flex flex-col md:flex-row md:items-end gap-3 mb-4">
<div class="flex-1">
<label for="unit-search" class="block text-sm font-medium mb-2">Buscar</label>
<InputText
id="unit-search"
v-model="search"
class="w-full"
placeholder="Buscar por nombre o abreviatura"
@keyup.enter="applyFilters"
/>
</div>
<div class="w-full md:w-64">
<label for="status-filter" class="block text-sm font-medium mb-2">Estado</label>
<Select
id="status-filter"
v-model="statusFilter"
class="w-full"
optionLabel="label"
optionValue="value"
:options="statusFilterOptions"
/>
</div>
<div class="flex gap-2">
<Button label="Filtrar" icon="pi pi-search" @click="applyFilters" />
<Button
label="Limpiar"
icon="pi pi-refresh"
severity="secondary"
outlined
@click="clearFilters"
/>
</div>
</div>
<!-- Loading State --> <!-- Loading State -->
<div v-if="loading && units.length === 0" class="flex justify-center items-center py-12"> <div v-if="loading && units.length === 0" class="flex justify-center items-center py-12">
<ProgressSpinner <ProgressSpinner
@ -266,6 +371,7 @@ onMounted(async () => {
<!-- Create/Edit Form Dialog --> <!-- Create/Edit Form Dialog -->
<UnitsForm <UnitsForm
ref="unitsFormRef"
v-model:visible="showDialog" v-model:visible="showDialog"
:unit="selectedUnit" :unit="selectedUnit"
:is-editing="isEditing" :is-editing="isEditing"

View File

@ -31,12 +31,14 @@ const emit = defineEmits<Emits>();
const formData = ref<CreateUnitOfMeasureData>({ const formData = ref<CreateUnitOfMeasureData>({
name: '', name: '',
abbreviation: '', abbreviation: '',
code_sat: 1, code_sat: null,
is_active: 1 is_active: true
}); });
// Estado interno del switch (boolean para el UI) // Estado interno del switch (boolean para el UI)
const isActiveSwitch = ref(true); const isActiveSwitch = ref(true);
const loading = ref(false);
const errors = ref<Record<string, string>>({});
// SAT Units // SAT Units
const satUnits = ref<SatUnit[]>([]); const satUnits = ref<SatUnit[]>([]);
@ -58,7 +60,7 @@ const dialogTitle = computed(() =>
); );
const isFormValid = computed(() => { const isFormValid = computed(() => {
return !!(formData.value.name && formData.value.abbreviation); return !!formData.value.name?.trim();
}); });
const emptyMessage = computed(() => { const emptyMessage = computed(() => {
@ -99,7 +101,7 @@ const loadSatUnits = async (search: string) => {
}; };
// Debounced search // Debounced search
const handleSearchChange = (event: any) => { const handleSearchChange = (event: { value?: string }) => {
const query = event.value || ''; const query = event.value || '';
searchQuery.value = query; searchQuery.value = query;
@ -120,9 +122,11 @@ const resetForm = () => {
name: '', name: '',
abbreviation: '', abbreviation: '',
code_sat: null, code_sat: null,
is_active: 1 is_active: true
}; };
isActiveSwitch.value = true; isActiveSwitch.value = true;
errors.value = {};
loading.value = false;
}; };
// Watch para actualizar el formulario cuando cambie la unidad // Watch para actualizar el formulario cuando cambie la unidad
@ -134,7 +138,7 @@ watch(() => props.unit, (newUnit) => {
code_sat: newUnit.code_sat, code_sat: newUnit.code_sat,
is_active: newUnit.is_active is_active: newUnit.is_active
}; };
isActiveSwitch.value = newUnit.is_active === 1; isActiveSwitch.value = newUnit.is_active;
// Precargar la unidad SAT actual en el dropdown // Precargar la unidad SAT actual en el dropdown
if (newUnit.sat_unit) { if (newUnit.sat_unit) {
@ -152,13 +156,57 @@ const handleClose = () => {
resetForm(); resetForm();
}; };
const handleSave = () => { const validateForm = (): boolean => {
// Convertir el switch boolean a number para el backend errors.value = {};
formData.value.is_active = isActiveSwitch.value ? 1 : 0;
emit('save', { ...formData.value }); if (!formData.value.name?.trim()) {
handleClose(); errors.value.name = 'El nombre es obligatorio';
} else if (formData.value.name.trim().length > 50) {
errors.value.name = 'El nombre no puede exceder 50 caracteres';
}
if (formData.value.abbreviation && formData.value.abbreviation.trim().length > 10) {
errors.value.abbreviation = 'La abreviatura no puede exceder 10 caracteres';
}
return Object.keys(errors.value).length === 0;
}; };
const handleSave = () => {
if (!validateForm()) {
return;
}
loading.value = true;
formData.value.is_active = isActiveSwitch.value;
emit('save', {
...formData.value,
name: formData.value.name.trim(),
abbreviation: formData.value.abbreviation?.trim() || undefined,
});
};
const setValidationErrors = (backendErrors: Record<string, string[]>) => {
loading.value = false;
errors.value = {};
Object.keys(backendErrors).forEach((key) => {
const firstError = backendErrors[key]?.[0];
if (firstError) {
errors.value[key] = firstError;
}
});
};
const resetSubmitting = () => {
loading.value = false;
};
defineExpose({
setValidationErrors,
resetSubmitting,
});
</script> </script>
<template> <template>
@ -183,7 +231,9 @@ const handleSave = () => {
class="w-full" class="w-full"
placeholder="Ej: PIEZA, KILOGRAMO, METRO" placeholder="Ej: PIEZA, KILOGRAMO, METRO"
:required="true" :required="true"
:class="{ 'p-invalid': errors.name }"
/> />
<small v-if="errors.name" class="p-error">{{ errors.name }}</small>
</div> </div>
<!-- Abbreviation --> <!-- Abbreviation -->
@ -196,8 +246,9 @@ const handleSave = () => {
v-model="formData.abbreviation" v-model="formData.abbreviation"
class="w-full" class="w-full"
placeholder="Ej: PZA, kg, m" placeholder="Ej: PZA, kg, m"
:required="true" :class="{ 'p-invalid': errors.abbreviation }"
/> />
<small v-if="errors.abbreviation" class="p-error">{{ errors.abbreviation }}</small>
</div> </div>
<!-- Code SAT --> <!-- Code SAT -->
@ -216,10 +267,12 @@ const handleSave = () => {
class="w-full" class="w-full"
:filter="true" :filter="true"
filterPlaceholder="Buscar unidad SAT" filterPlaceholder="Buscar unidad SAT"
:showClear="false" :showClear="true"
@filter="handleSearchChange" @filter="handleSearchChange"
:emptyFilterMessage="emptyMessage" :emptyFilterMessage="emptyMessage"
:class="{ 'p-invalid': errors.code_sat }"
/> />
<small v-if="errors.code_sat" class="p-error">{{ errors.code_sat }}</small>
<small class="text-surface-500 dark:text-surface-400"> <small class="text-surface-500 dark:text-surface-400">
Unidad del catálogo SAT Unidad del catálogo SAT
</small> </small>
@ -253,6 +306,7 @@ const handleSave = () => {
<Button <Button
:label="isEditing ? 'Actualizar' : 'Crear'" :label="isEditing ? 'Actualizar' : 'Crear'"
:disabled="!isFormValid" :disabled="!isFormValid"
:loading="loading"
@click="handleSave" @click="handleSave"
/> />
</div> </div>

View File

@ -11,7 +11,14 @@ export const satUnitsService = {
* Get all SAT units with search filter * Get all SAT units with search filter
*/ */
async getSatUnits(search: string = ''): Promise<SatUnitsResponse> { async getSatUnits(search: string = ''): Promise<SatUnitsResponse> {
const response = await api.get(`/api/sat/units?search=${search}`); try {
const response = await api.get('/api/sat/units', {
params: { search },
});
return response.data; return response.data;
} catch (error) {
console.error('Error al obtener unidades SAT:', error);
throw error;
}
} }
}; };

View File

@ -0,0 +1,40 @@
import type {
CreateUnitOfMeasureData,
UnitOfMeasure,
UnitOfMeasureApi,
UpdateUnitOfMeasureData,
} from '../types/unit-measure.interfaces';
const toBoolean = (value: boolean | number | undefined): boolean => {
if (typeof value === 'boolean') {
return value;
}
return value === 1;
};
export const mapUnitFromApi = (apiUnit: UnitOfMeasureApi): UnitOfMeasure => ({
id: apiUnit.id,
name: apiUnit.name,
abbreviation: apiUnit.abbreviation,
is_active: toBoolean(apiUnit.is_active),
created_at: apiUnit.created_at,
updated_at: apiUnit.updated_at,
deleted_at: apiUnit.deleted_at,
code_sat: apiUnit.code_sat,
sat_unit: apiUnit.sat_unit,
});
export const mapCreatePayload = (data: CreateUnitOfMeasureData): CreateUnitOfMeasureData => ({
name: data.name.trim(),
abbreviation: data.abbreviation?.trim() || undefined,
code_sat: data.code_sat ?? null,
is_active: data.is_active ?? true,
});
export const mapUpdatePayload = (data: UpdateUnitOfMeasureData): UpdateUnitOfMeasureData => ({
name: data.name?.trim(),
abbreviation: data.abbreviation?.trim() || undefined,
code_sat: data.code_sat ?? null,
is_active: data.is_active,
});

View File

@ -1,52 +1,124 @@
import api from '../../../services/api'; import api from '../../../services/api';
import type { import type {
UnitOfMeasureApi,
UnitOfMeasurePaginatedApiResponse,
UnitOfMeasureUnpaginatedApiResponse,
UnitOfMeasurePaginatedResponse, UnitOfMeasurePaginatedResponse,
UnitOfMeasureUnpaginatedResponse, UnitOfMeasureUnpaginatedResponse,
CreateUnitOfMeasureData, CreateUnitOfMeasureData,
UpdateUnitOfMeasureData, UpdateUnitOfMeasureData,
SingleUnitOfMeasureResponse, SingleUnitOfMeasureResponse,
UnitOfMeasureResponseById UnitOfMeasureResponseById,
UnitOfMeasureQueryParams
} from '../types/unit-measure.interfaces'; } from '../types/unit-measure.interfaces';
import { mapCreatePayload, mapUnitFromApi, mapUpdatePayload } from './unit-measure.mapper';
const mapPaginatedResponse = (
response: UnitOfMeasurePaginatedApiResponse
): UnitOfMeasurePaginatedResponse => ({
...response,
data: response.data.map(mapUnitFromApi),
});
const mapUnpaginatedResponse = (
response: UnitOfMeasureUnpaginatedApiResponse
): UnitOfMeasureUnpaginatedResponse => ({
data: response.data.map(mapUnitFromApi),
});
const mapSingleResponse = (response: { message: string; data: UnitOfMeasureApi }): SingleUnitOfMeasureResponse => ({
message: response.message,
data: mapUnitFromApi(response.data),
});
export const unitOfMeasureService = { export const unitOfMeasureService = {
/** /**
* Get all units of measure with optional pagination and search * Get all units of measure with optional pagination and search
*/ */
async getUnits(paginate: boolean = true, search: string = ''): Promise<UnitOfMeasurePaginatedResponse | UnitOfMeasureUnpaginatedResponse> { async getUnits(params: UnitOfMeasureQueryParams = {}): Promise<UnitOfMeasurePaginatedResponse | UnitOfMeasureUnpaginatedResponse> {
const response = await api.get(`/api/catalogs/units-of-measure?paginate=${paginate}&search=${search}`); try {
return response.data; const {
search = '',
is_active,
paginate = true,
per_page,
page,
} = params;
const response = await api.get('/api/catalogs/units-of-measure', {
params: {
search,
is_active,
paginate,
per_page,
page,
},
});
if ('current_page' in response.data) {
return mapPaginatedResponse(response.data as UnitOfMeasurePaginatedApiResponse);
}
return mapUnpaginatedResponse(response.data as UnitOfMeasureUnpaginatedApiResponse);
} catch (error) {
console.error('Error al obtener unidades de medida:', error);
throw error;
}
}, },
/** /**
* Get a single unit of measure by ID * Get a single unit of measure by ID
*/ */
async getUnitById(id: number): Promise<UnitOfMeasureResponseById> { async getUnitById(id: number): Promise<UnitOfMeasureResponseById> {
try {
const response = await api.get(`/api/catalogs/units-of-measure/${id}`); const response = await api.get(`/api/catalogs/units-of-measure/${id}`);
return response.data; return {
data: mapUnitFromApi((response.data as { data: UnitOfMeasureApi }).data),
};
} catch (error) {
console.error(`Error al obtener unidad de medida con id ${id}:`, error);
throw error;
}
}, },
/** /**
* Create a new unit of measure * Create a new unit of measure
*/ */
async createUnit(data: CreateUnitOfMeasureData): Promise<SingleUnitOfMeasureResponse> { async createUnit(data: CreateUnitOfMeasureData): Promise<SingleUnitOfMeasureResponse> {
const response = await api.post(`/api/catalogs/units-of-measure`, data); try {
console.log('Create Unit response:', response); const payload = mapCreatePayload(data);
return response.data; const response = await api.post('/api/catalogs/units-of-measure', payload);
return mapSingleResponse(response.data as { message: string; data: UnitOfMeasureApi });
} catch (error) {
console.error('Error al crear unidad de medida:', error);
throw error;
}
}, },
/** /**
* Update an existing unit of measure * Update an existing unit of measure
*/ */
async updateUnit(id: number, data: UpdateUnitOfMeasureData): Promise<SingleUnitOfMeasureResponse> { async updateUnit(id: number, data: UpdateUnitOfMeasureData): Promise<SingleUnitOfMeasureResponse> {
const response = await api.put(`/api/catalogs/units-of-measure/${id}`, data); try {
return response.data; const payload = mapUpdatePayload(data);
const response = await api.put(`/api/catalogs/units-of-measure/${id}`, payload);
return mapSingleResponse(response.data as { message: string; data: UnitOfMeasureApi });
} catch (error) {
console.error(`Error al actualizar unidad de medida con id ${id}:`, error);
throw error;
}
}, },
/** /**
* Delete a unit of measure * Delete a unit of measure
*/ */
async deleteUnit(id: number): Promise<void> { async deleteUnit(id: number): Promise<void> {
try {
await api.delete(`/api/catalogs/units-of-measure/${id}`); await api.delete(`/api/catalogs/units-of-measure/${id}`);
} catch (error) {
console.error(`Error al eliminar unidad de medida con id ${id}:`, error);
throw error;
}
} }
}; };

View File

@ -4,23 +4,40 @@ import { unitOfMeasureService } from '../services/unit-measure.services';
import type { import type {
UnitOfMeasure, UnitOfMeasure,
CreateUnitOfMeasureData, CreateUnitOfMeasureData,
UpdateUnitOfMeasureData UpdateUnitOfMeasureData,
UnitOfMeasureQueryParams,
UnitOfMeasurePaginatedResponse,
UnitOfMeasureUnpaginatedResponse,
} from '../types/unit-measure.interfaces'; } from '../types/unit-measure.interfaces';
interface FetchUnitsOptions extends UnitOfMeasureQueryParams {
force?: boolean;
}
const isPaginatedResponse = (
response: UnitOfMeasurePaginatedResponse | UnitOfMeasureUnpaginatedResponse
): response is UnitOfMeasurePaginatedResponse => 'current_page' in response;
export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => { export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
// State // State
const units = ref<UnitOfMeasure[]>([]); const units = ref<UnitOfMeasure[]>([]);
const loading = ref(false); const loading = ref(false);
const loaded = ref(false); const loaded = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const lastFetchOptions = ref<UnitOfMeasureQueryParams>({
paginate: true,
search: '',
page: 1,
per_page: 10,
});
// Getters // Getters
const activeUnits = computed(() => const activeUnits = computed(() =>
units.value.filter(unit => unit.is_active === 1) units.value.filter((unit) => unit.is_active)
); );
const inactiveUnits = computed(() => const inactiveUnits = computed(() =>
units.value.filter(unit => unit.is_active === 0) units.value.filter((unit) => !unit.is_active)
); );
const unitCount = computed(() => units.value.length); const unitCount = computed(() => units.value.length);
@ -35,31 +52,35 @@ export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
}); });
// Actions // Actions
const fetchUnits = async (force = false, paginate = true, search = '') => { const fetchUnits = async (options: FetchUnitsOptions = {}) => {
// Si ya están cargados y no se fuerza la recarga, no hacer nada const {
if (loaded.value && !force) { force = false,
console.log('Units of measure already loaded from store'); ...queryOptions
} = options;
if (loaded.value && !force && units.value.length > 0) {
return; return;
} }
const normalizedOptions: UnitOfMeasureQueryParams = {
...lastFetchOptions.value,
...queryOptions,
};
try { try {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
const response = await unitOfMeasureService.getUnits(paginate, search); const response = await unitOfMeasureService.getUnits(normalizedOptions);
// Manejar respuesta paginada o no paginada if (isPaginatedResponse(response)) {
if ('current_page' in response) {
// Respuesta paginada
units.value = response.data; units.value = response.data;
} else { } else {
// Respuesta no paginada
units.value = response.data; units.value = response.data;
} }
loaded.value = true; loaded.value = true;
lastFetchOptions.value = normalizedOptions;
console.log('Units of measure loaded into store:', units.value.length);
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Error loading units of measure'; error.value = err instanceof Error ? err.message : 'Error loading units of measure';
console.error('Error in unit of measure store:', err); console.error('Error in unit of measure store:', err);
@ -69,8 +90,12 @@ export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
} }
}; };
const refreshUnits = () => { const refreshUnits = (overrides: UnitOfMeasureQueryParams = {}) => {
return fetchUnits(true); return fetchUnits({
force: true,
...lastFetchOptions.value,
...overrides,
});
}; };
const createUnit = async (data: CreateUnitOfMeasureData) => { const createUnit = async (data: CreateUnitOfMeasureData) => {
@ -82,8 +107,6 @@ export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
// Refrescar la lista después de crear // Refrescar la lista después de crear
await refreshUnits(); await refreshUnits();
console.log('Unit of measure created successfully');
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Error creating unit of measure'; error.value = err instanceof Error ? err.message : 'Error creating unit of measure';
console.error('Error creating unit of measure:', err); console.error('Error creating unit of measure:', err);
@ -102,8 +125,6 @@ export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
// Refrescar la lista después de actualizar // Refrescar la lista después de actualizar
await refreshUnits(); await refreshUnits();
console.log('Unit of measure updated successfully');
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Error updating unit of measure'; error.value = err instanceof Error ? err.message : 'Error updating unit of measure';
console.error('Error updating unit of measure:', err); console.error('Error updating unit of measure:', err);
@ -122,8 +143,6 @@ export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
// Refrescar la lista después de eliminar // Refrescar la lista después de eliminar
await refreshUnits(); await refreshUnits();
console.log('Unit of measure deleted successfully');
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Error deleting unit of measure'; error.value = err instanceof Error ? err.message : 'Error deleting unit of measure';
console.error('Error deleting unit of measure:', err); console.error('Error deleting unit of measure:', err);
@ -145,6 +164,7 @@ export const useUnitOfMeasureStore = defineStore('unitOfMeasure', () => {
loading, loading,
loaded, loaded,
error, error,
lastFetchOptions,
// Getters // Getters
activeUnits, activeUnits,
inactiveUnits, inactiveUnits,

View File

@ -8,32 +8,50 @@ export interface SatUnit {
updated_at: string; updated_at: string;
} }
// Interface para la Unidad de Medida export interface UnitOfMeasureApi {
id: number;
name: string;
abbreviation: string;
is_active: boolean | number;
created_at: string;
updated_at: string;
deleted_at: string | null;
code_sat: number | null;
sat_unit: SatUnit | null;
}
export interface UnitOfMeasure { export interface UnitOfMeasure {
id: number; id: number;
name: string; name: string;
abbreviation: string; abbreviation: string;
is_active: number; is_active: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
deleted_at: string | null; deleted_at: string | null;
code_sat: number; code_sat: number | null;
sat_unit: SatUnit; sat_unit: SatUnit | null;
} }
// Interfaces para crear y actualizar unidades de medida
export interface CreateUnitOfMeasureData { export interface CreateUnitOfMeasureData {
name: string; name: string;
abbreviation: string; abbreviation?: string;
code_sat: number | null; code_sat?: number | null;
is_active: number; is_active?: boolean;
} }
export interface UpdateUnitOfMeasureData { export interface UpdateUnitOfMeasureData {
name?: string; name?: string;
abbreviation?: string; abbreviation?: string;
code_sat?: number | null; code_sat?: number | null;
is_active?: number; is_active?: boolean;
}
export interface UnitOfMeasureQueryParams {
search?: string;
is_active?: boolean;
paginate?: boolean;
per_page?: number;
page?: number;
} }
// Interface para los links de paginación // Interface para los links de paginación
@ -65,10 +83,12 @@ export interface UnpaginatedResponse<T> {
data: T[]; data: T[];
} }
// Tipo específico para la respuesta paginada de Unidades de Medida export type UnitOfMeasurePaginatedApiResponse = PaginatedResponse<UnitOfMeasureApi>;
export type UnitOfMeasureUnpaginatedApiResponse = UnpaginatedResponse<UnitOfMeasureApi>;
export type UnitOfMeasurePaginatedResponse = PaginatedResponse<UnitOfMeasure>; export type UnitOfMeasurePaginatedResponse = PaginatedResponse<UnitOfMeasure>;
// Tipo específico para la respuesta no paginada de Unidades de Medida
export type UnitOfMeasureUnpaginatedResponse = UnpaginatedResponse<UnitOfMeasure>; export type UnitOfMeasureUnpaginatedResponse = UnpaginatedResponse<UnitOfMeasure>;
export type UnitOfMeasureResponseById = { export type UnitOfMeasureResponseById = {

View File

@ -1,5 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import type { InternalAxiosRequestConfig } from 'axios'; import type { InternalAxiosRequestConfig } from 'axios';
import { AUTH_TOKEN_KEY, clearStoredAuthSession } from '../modules/auth/utils/authStorage';
const api = axios.create({ const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000', baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
@ -12,7 +13,7 @@ const api = axios.create({
// Interceptor para agregar el Bearer Token a cada petición // Interceptor para agregar el Bearer Token a cada petición
api.interceptors.request.use( api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('auth_token'); // Ajusta según dónde guardes el token const token = localStorage.getItem(AUTH_TOKEN_KEY);
if (token && config.headers) { if (token && config.headers) {
config.headers['Authorization'] = `Bearer ${token}`; config.headers['Authorization'] = `Bearer ${token}`;
} }
@ -28,14 +29,12 @@ api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response) { if (error.response) {
// El servidor respondió con un código de estado fuera del rango 2xx
console.error(error.response.data);
// Manejar error 401 (no autorizado) - redirigir al login
if (error.response.status === 401) { if (error.response.status === 401) {
localStorage.removeItem('auth_token'); clearStoredAuthSession();
localStorage.removeItem('auth_user');
window.location.href = '/login'; if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
}
} }
} }
return Promise.reject(error); return Promise.reject(error);