feat: add departments and employees management components #17

Merged
edgar.mendez merged 1 commits from feature-comercial-module-ts into qa 2026-03-10 23:15:06 +00:00
17 changed files with 1813 additions and 34 deletions

View File

@ -64,7 +64,8 @@ const menuItems = ref<MenuItem[]>([
icon: 'pi pi-users',
items: [
{ label: 'Puestos laborales', icon: 'pi pi-user', to: '/rh/positions' },
{ label: 'Departamentos', icon: 'pi pi-briefcase', to: '/rh/departments' }
{ label: 'Departamentos', icon: 'pi pi-briefcase', to: '/rh/departments' },
{ label: 'Empleados', icon: 'pi pi-id-card', to: '/rh/employees' }
]
},
{

View File

@ -14,8 +14,8 @@ import Dialog from 'primevue/dialog';
import Toast from 'primevue/toast';
import { useRequisitionStore } from './stores/requisitionStore';
import type { RequisitionItem } from './types/requisition.interfaces';
import { DepartmentsService } from '@/modules/rh/services/departments.services';
import type { Department } from '@/modules/rh/types/departments.interface';
import { DepartmentsService } from '@/modules/rh/components/departments/departments.services';
import type { Department } from '@/modules/rh/components/departments/departments.interface';
const router = useRouter();
const route = useRoute();

View File

@ -16,8 +16,8 @@ import Dialog from 'primevue/dialog';
import Textarea from 'primevue/textarea';
import { useRequisitionStore } from './stores/requisitionStore';
import type { Requisition } from './types/requisition.interfaces';
import { DepartmentsService } from '@/modules/rh/services/departments.services';
import type { Department } from '@/modules/rh/types/departments.interface';
import { DepartmentsService } from '@/modules/rh/components/departments/departments.services';
import type { Department } from '@/modules/rh/components/departments/departments.interface';
const router = useRouter();
const toast = useToast();

View File

@ -0,0 +1,269 @@
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
:modal="true"
:style="{ width: '900px' }"
:closable="true"
class="p-0"
>
<template #header>
<div class="flex flex-col gap-1 w-full">
<h2 class="text-gray-900 dark:text-white text-2xl font-bold tracking-tight">
Detalles del Departamento
</h2>
<p class="text-gray-500 dark:text-gray-400 text-base font-normal leading-normal">
Información completa del departamento y su estructura organizacional
</p>
</div>
</template>
<div v-if="loading" class="p-8 flex justify-center items-center">
<ProgressSpinner style="width: 50px; height: 50px" strokeWidth="4" />
</div>
<div v-else-if="department" class="p-6 pt-0 space-y-6">
<!-- Department Info Card -->
<Card class="shadow-sm border border-gray-200 dark:border-gray-800">
<template #content>
<div class="space-y-4">
<div class="flex items-start gap-4">
<div
class="size-16 rounded-full flex items-center justify-center text-white font-bold text-2xl shrink-0"
:style="{ backgroundColor: department.color || '#3b82f6' }"
>
<i :class="department.icon || 'pi pi-building'"></i>
</div>
<div class="flex-1">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">
{{ department.name }}
</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm">
{{ department.description }}
</p>
</div>
</div>
</div>
</template>
</Card>
<!-- Parent Department -->
<div v-if="department.parent" class="space-y-3">
<h4 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<i class="pi pi-sitemap text-primary"></i>
Departamento Padre
</h4>
<Card class="shadow-sm border border-gray-200 dark:border-gray-800">
<template #content>
<div class="flex items-center gap-3">
<div class="size-10 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
<i class="pi pi-building text-gray-600 dark:text-gray-400"></i>
</div>
<div>
<p class="font-semibold text-gray-900 dark:text-white">
{{ department.parent.name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ department.parent.description }}
</p>
</div>
</div>
</template>
</Card>
</div>
<div v-else class="space-y-3">
<h4 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<i class="pi pi-sitemap text-primary"></i>
Jerarquía
</h4>
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<p class="text-sm text-blue-800 dark:text-blue-300 flex items-center gap-2">
<i class="pi pi-info-circle"></i>
Este es un departamento de nivel raíz (sin departamento padre)
</p>
</div>
</div>
<!-- Children Departments (Subdepartamentos) -->
<div class="space-y-3">
<h4 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<i class="pi pi-share-alt text-primary"></i>
Subdepartamentos
<Tag :value="department.children?.length || 0" severity="success" />
</h4>
<div v-if="department.children && department.children.length > 0" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<Card
v-for="child in department.children"
:key="child.id"
class="shadow-sm border border-gray-200 dark:border-gray-800 hover:shadow-md transition-shadow cursor-pointer"
>
<template #content>
<div class="flex items-center gap-3">
<div class="size-10 rounded-full bg-primary-100 dark:bg-primary-900/30 flex items-center justify-center">
<i class="pi pi-building text-primary-600 dark:text-primary-400"></i>
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-gray-900 dark:text-white truncate">
{{ child.name }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
{{ child.description }}
</p>
</div>
</div>
</template>
</Card>
</div>
<div v-else class="p-8 text-center bg-gray-50 dark:bg-gray-900/30 rounded-lg border border-gray-200 dark:border-gray-800">
<i class="pi pi-folder-open text-4xl text-gray-300 dark:text-gray-700 mb-3"></i>
<p class="text-gray-500 dark:text-gray-400">
No hay subdepartamentos asignados
</p>
</div>
</div>
<!-- Job Positions -->
<div class="space-y-3">
<h4 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<i class="pi pi-users text-primary"></i>
Puestos Laborales
<Tag :value="department.job_positions?.length || 0" severity="info" />
</h4>
<div v-if="department.job_positions && department.job_positions.length > 0" class="space-y-2">
<Card
v-for="position in department.job_positions"
:key="position.id"
class="shadow-sm border border-gray-200 dark:border-gray-800"
>
<template #content>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<span class="px-2.5 py-1 rounded-full text-xs font-bold bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
{{ position.code }}
</span>
<h5 class="font-semibold text-gray-900 dark:text-white">
{{ position.name }}
</h5>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ position.description }}
</p>
<div v-if="position.parent_id" class="mt-2 flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
<i class="pi pi-arrow-up-right"></i>
<span>Reporta a puesto ID: {{ position.parent_id }}</span>
</div>
</div>
<Tag
:value="position.is_active ? 'Activo' : 'Inactivo'"
:severity="position.is_active ? 'success' : 'danger'"
/>
</div>
</template>
</Card>
</div>
<div v-else class="p-8 text-center bg-gray-50 dark:bg-gray-900/30 rounded-lg border border-gray-200 dark:border-gray-800">
<i class="pi pi-inbox text-4xl text-gray-300 dark:text-gray-700 mb-3"></i>
<p class="text-gray-500 dark:text-gray-400">
No hay puestos laborales asignados a este departamento
</p>
</div>
</div>
<!-- Metadata -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-800">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500 dark:text-gray-400">Creado:</span>
<span class="ml-2 text-gray-900 dark:text-white font-medium">
{{ formatDate(department.created_at) }}
</span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Actualizado:</span>
<span class="ml-2 text-gray-900 dark:text-white font-medium">
{{ formatDate(department.updated_at) }}
</span>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-4 pt-4 border-t border-gray-200 dark:border-gray-800">
<Button
label="Cerrar"
icon="pi pi-times"
severity="secondary"
@click="$emit('update:visible', false)"
class="px-6"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import Card from 'primevue/card';
import Tag from 'primevue/tag';
import ProgressSpinner from 'primevue/progressspinner';
import type { Department } from './departments.interface';
import { DepartmentsService } from './departments.services';
interface Props {
visible: boolean;
departmentId: number | null;
}
interface Emits {
(e: 'update:visible', value: boolean): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const departmentsService = new DepartmentsService();
const loading = ref(false);
const department = ref<Department | null>(null);
// Fetch department details when modal opens
watch(() => [props.visible, props.departmentId], async ([isVisible, id]) => {
if (isVisible && id) {
await fetchDepartmentDetails(id as number);
}
}, { immediate: true });
const fetchDepartmentDetails = async (id: number) => {
loading.value = true;
try {
const response = await departmentsService.getDepartmentById(id);
department.value = response.data;
} catch (error) {
console.error('Error loading department details:', error);
department.value = null;
} finally {
loading.value = false;
}
};
const formatDate = (date: Date | string) => {
return new Date(date).toLocaleDateString('es-MX', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
</script>
<style scoped>
/* Estilos adicionales si son necesarios */
</style>

View File

@ -34,6 +34,25 @@
</p>
</div>
<!-- Parent Department Dropdown -->
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
Departamento Padre (Opcional)
</label>
<Dropdown
v-model="localFormData.parent_id"
:options="departmentOptions"
optionLabel="label"
optionValue="value"
placeholder="Seleccionar departamento padre"
class="w-full h-14"
showClear
/>
<p class="text-gray-500 dark:text-gray-400 text-xs font-normal">
Seleccione un departamento padre para crear una jerarquía organizacional. Déjelo vacío para departamentos de nivel raíz.
</p>
</div>
<!-- Department Description Field -->
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
@ -69,17 +88,19 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { ref, watch, computed } from 'vue';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
import type { Department } from '../../types/departments.interface';
import Dropdown from 'primevue/dropdown';
import type { Department } from './departments.interface';
interface Props {
visible: boolean;
mode: 'create' | 'edit';
formData: Partial<Department>;
departments?: Department[];
}
interface Emits {
@ -93,6 +114,28 @@ const emit = defineEmits<Emits>();
const localFormData = ref<Partial<Department>>({ ...props.formData });
// Computed: Lista de departamentos disponibles para seleccionar como padre
// Excluir el departamento actual si estamos en modo edición
const availableDepartments = computed(() => {
if (!props.departments) return [];
// Si estamos editando, excluir el departamento actual para evitar referencias circulares
if (props.mode === 'edit' && localFormData.value.id) {
return props.departments.filter(dept => dept.id !== localFormData.value.id);
}
return props.departments;
});
// Opciones para el dropdown (incluye opción "Sin departamento padre")
const departmentOptions = computed(() => [
{ label: 'Sin departamento padre (Nivel raíz)', value: null },
...availableDepartments.value.map(dept => ({
label: dept.name,
value: dept.id
}))
]);
// Watch for external formData changes
watch(() => props.formData, (newData) => {
localFormData.value = { ...newData };

View File

@ -47,9 +47,14 @@
>
<i :class="slotProps.data.icon"></i>
</div>
<span class="font-semibold text-gray-900 dark:text-white">
{{ slotProps.data.name }}
</span>
<div class="flex flex-col">
<span class="font-semibold text-gray-900 dark:text-white">
{{ slotProps.data.name }}
</span>
<span v-if="slotProps.data.parent" class="text-xs text-gray-500 dark:text-gray-400">
<i class="pi pi-arrow-up-right text-xs"></i> {{ slotProps.data.parent.name }}
</span>
</div>
</div>
</template>
</Column>
@ -100,10 +105,17 @@
v-model:visible="showDialog"
:mode="dialogMode"
:formData="formData"
:departments="departments"
@save="saveDepartment"
@cancel="showDialog = false"
/>
<!-- Detail Modal -->
<DepartmentDetailModal
v-model:visible="showDetailModal"
:departmentId="selectedDepartmentId"
/>
<!-- Delete Confirmation Dialog -->
<Dialog
v-model:visible="showDeleteDialog"
@ -143,7 +155,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { DepartmentsService } from '../../services/departments.services';
import { DepartmentsService } from './departments.services';
// PrimeVue Components
import Toast from 'primevue/toast';
@ -154,8 +166,9 @@ import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
import type { Department, CreateDepartmentDTO, UpdateDepartmentDTO } from '../../types/departments.interface';
import type { Department, CreateDepartmentDTO, UpdateDepartmentDTO } from './departments.interface';
import DepartmentForm from './DepartmentForm.vue';
import DepartmentDetailModal from './DepartmentDetailModal.vue';
const toast = useToast();
const departmentsService = new DepartmentsService();
@ -164,8 +177,10 @@ const departmentsService = new DepartmentsService();
const loading = ref(false);
const showDialog = ref(false);
const showDeleteDialog = ref(false);
const showDetailModal = ref(false);
const dialogMode = ref<'create' | 'edit'>('create');
const departmentToDelete = ref<Department | null>(null);
const selectedDepartmentId = ref<number | null>(null);
// Breadcrumb
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
@ -225,6 +240,7 @@ const openCreateDialog = () => {
formData.value = {
name: '',
description: '',
parent_id: null,
employeeCount: 0,
color: '#3b82f6',
icon: 'pi pi-building'
@ -233,12 +249,8 @@ const openCreateDialog = () => {
};
const viewDepartment = (department: Department) => {
toast.add({
severity: 'info',
summary: 'Ver Departamento',
detail: `Visualizando: ${department.name}`,
life: 3000
});
selectedDepartmentId.value = department.id;
showDetailModal.value = true;
};
const editDepartment = (department: Department) => {
@ -297,7 +309,8 @@ const saveDepartment = async (data: Partial<Department>) => {
// Create new department
const createData: CreateDepartmentDTO = {
name: data.name,
description: data.description || ''
description: data.description || '',
parent_id: data.parent_id || null
};
await departmentsService.createDepartment(createData);
@ -322,7 +335,8 @@ const saveDepartment = async (data: Partial<Department>) => {
const updateData: UpdateDepartmentDTO = {
name: data.name,
description: data.description
description: data.description,
parent_id: data.parent_id || null
};
await departmentsService.updateDepartment(data.id, updateData);

View File

@ -1,7 +1,24 @@
export interface JobPosition {
id: number;
name: string;
code: string;
description: string;
is_active: number;
created_at: Date;
updated_at: Date;
deleted_at: Date | null;
parent_id?: number | null;
department_id: number;
}
export interface Department {
id: number;
name: string;
description: string;
parent_id?: number | null;
parent?: Department;
children?: Department[];
job_positions?: JobPosition[];
employeeCount?: number;
color?: string;
icon?: string;
@ -14,6 +31,10 @@ export interface ResponseDepartmentsDTO {
data: Department[];
}
export interface ResponseDepartmentDTO {
data: Department;
}
export interface DeleteDepartmentDTO {
message: string;
}
@ -21,6 +42,7 @@ export interface DeleteDepartmentDTO {
export interface CreateDepartmentDTO {
name: string;
description: string;
parent_id?: number | null;
}
export interface UpdateDepartmentDTO extends Partial<CreateDepartmentDTO> {}

View File

@ -1,5 +1,5 @@
import type { CreateDepartmentDTO, DeleteDepartmentDTO, ResponseDepartmentsDTO, UpdateDepartmentDTO } from "../types/departments.interface";
import type { CreateDepartmentDTO, DeleteDepartmentDTO, ResponseDepartmentsDTO, ResponseDepartmentDTO, UpdateDepartmentDTO } from "./departments.interface";
import api from "@/services/api";
export class DepartmentsService {
@ -14,6 +14,16 @@ export class DepartmentsService {
}
}
public async getDepartmentById(id: number): Promise<ResponseDepartmentDTO> {
try {
const response = await api.get(`/api/rh/departments/${id}`);
return response.data;
} catch (error) {
console.error('Error fetching department:', error);
throw error;
}
}
public async createDepartment(data: CreateDepartmentDTO): Promise<ResponseDepartmentsDTO> {
try {
const response = await api.post('/api/rh/departments', data);

View File

@ -0,0 +1,512 @@
<template>
<div class="space-y-6">
<!-- Toast Notifications -->
<Toast position="bottom-right" />
<!-- Breadcrumb -->
<Breadcrumb :home="breadcrumbHome" :model="breadcrumbItems" />
<!-- Page Header -->
<div class="flex flex-wrap items-baseline justify-between gap-4">
<div class="flex flex-col gap-2">
<h1 class="text-gray-900 dark:text-white text-3xl md:text-4xl font-black leading-tight tracking-tight">
Gestión de Empleados
</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm">
Total: {{ employees.length }} empleados registrados
</p>
</div>
<Button
label="Nuevo Empleado"
icon="pi pi-plus"
@click="openCreateDialog"
/>
</div>
<!-- Employees Table -->
<Card class="shadow-sm">
<template #content>
<DataTable
:value="employees"
:paginator="true"
:rows="10"
:rowsPerPageOptions="[10, 25, 50]"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown CurrentPageReport"
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} empleados"
:loading="loading"
stripedRows
responsiveLayout="scroll"
class="text-sm"
>
<Column header="Empleado" sortable>
<template #body="slotProps">
<div class="flex items-center gap-3">
<img
:src="slotProps.data.user.profile_photo_url"
:alt="getFullName(slotProps.data)"
class="w-10 h-10 rounded-full"
/>
<div class="flex flex-col">
<span class="font-semibold text-gray-900 dark:text-white">
{{ getFullName(slotProps.data) }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ slotProps.data.user.email }}
</span>
</div>
</div>
</template>
</Column>
<Column field="gender" header="Género" sortable>
<template #body="slotProps">
<Tag
:value="slotProps.data.gender === 1 ? 'Masculino' : 'Femenino'"
:severity="slotProps.data.gender === 1 ? 'info' : 'warn'"
/>
</template>
</Column>
<Column field="department" header="Departamento" sortable>
<template #body="slotProps">
<span v-if="slotProps.data.department" class="text-sm text-gray-700 dark:text-gray-300">
<i class="pi pi-building text-xs mr-1"></i>
{{ slotProps.data.department.name }}
</span>
<span v-else class="text-xs text-gray-400 italic">
Sin asignar
</span>
</template>
</Column>
<Column field="job_position" header="Puesto" sortable>
<template #body="slotProps">
<span v-if="slotProps.data.job_position" class="text-sm text-gray-700 dark:text-gray-300">
<i class="pi pi-briefcase text-xs mr-1"></i>
{{ slotProps.data.job_position.name }}
</span>
<span v-else class="text-xs text-gray-400 italic">
Sin asignar
</span>
</template>
</Column>
<Column field="hire_date" header="Fecha de Contratación" sortable>
<template #body="slotProps">
<span v-if="slotProps.data.hire_date" class="text-sm text-gray-600 dark:text-gray-400">
{{ formatDate(slotProps.data.hire_date) }}
</span>
<span v-else class="text-xs text-gray-400 italic">
No registrada
</span>
</template>
</Column>
<Column field="user.status" header="Estado" sortable>
<template #body="slotProps">
<Tag
:value="slotProps.data.user.status === 1 ? 'Activo' : 'Inactivo'"
:severity="slotProps.data.user.status === 1 ? 'success' : 'danger'"
/>
</template>
</Column>
<Column header="Acciones" :exportable="false">
<template #body="slotProps">
<div class="flex gap-2">
<Button
icon="pi pi-eye"
outlined
rounded
size="small"
severity="secondary"
@click="viewEmployee(slotProps.data)"
/>
<Button
icon="pi pi-pencil"
outlined
rounded
size="small"
@click="editEmployee(slotProps.data)"
/>
<Button
icon="pi pi-trash"
outlined
rounded
size="small"
severity="danger"
@click="confirmDelete(slotProps.data)"
/>
</div>
</template>
</Column>
</DataTable>
</template>
</Card>
<!-- Delete Confirmation Dialog -->
<Dialog
v-model:visible="showDeleteDialog"
header="Confirmar Eliminación"
:modal="true"
:style="{ width: '450px' }"
>
<div class="flex items-start gap-4">
<i class="pi pi-exclamation-triangle text-3xl text-red-500"></i>
<div>
<p class="text-gray-900 dark:text-white mb-2">
¿Estás seguro de que deseas eliminar al empleado <strong>{{ employeeToDelete ? getFullName(employeeToDelete) : '' }}</strong>?
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
Esta acción no se puede deshacer.
</p>
</div>
</div>
<template #footer>
<Button
label="Cancelar"
severity="secondary"
@click="showDeleteDialog = false"
/>
<Button
label="Eliminar"
severity="danger"
icon="pi pi-trash"
@click="deleteEmployee"
/>
</template>
</Dialog>
<!-- Create/Edit Employee Form -->
<EmployeesForm
ref="employeeFormRef"
v-model:visible="showDialog"
:mode="dialogMode"
:formData="formData"
:departments="departments"
:positions="positions"
@save="saveEmployee"
@cancel="showDialog = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { employeesService } from './employees.services';
import type { Employee, CreateEmployeeDTO, UpdateEmployeeDTO, DepartmentReference, JobPositionReference } from './employees.interfaces';
import { DepartmentsService } from '../departments/departments.services';
import { PositionsService } from '../positions/positions.services';
// PrimeVue Components
import Toast from 'primevue/toast';
import Breadcrumb from 'primevue/breadcrumb';
import Card from 'primevue/card';
import Button from 'primevue/button';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import Dialog from 'primevue/dialog';
import Tag from 'primevue/tag';
import EmployeesForm from './EmployeesForm.vue';
const toast = useToast();
const departmentsService = new DepartmentsService();
const positionsService = new PositionsService();
// State
const loading = ref(false);
const showDialog = ref(false);
const showDeleteDialog = ref(false);
const dialogMode = ref<'create' | 'edit'>('create');
const employeeToDelete = ref<Employee | null>(null);
const employeeFormRef = ref<InstanceType<typeof EmployeesForm> | null>(null);
// Breadcrumb
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
const breadcrumbItems = ref([
{ label: 'RH', to: '/rh' },
{ label: 'Empleados' }
]);
// Data
const employees = ref<Employee[]>([]);
const departments = ref<DepartmentReference[]>([]);
const positions = ref<JobPositionReference[]>([]);
// Form Data
const formData = ref<CreateEmployeeDTO | UpdateEmployeeDTO>({
name: '',
paternal: '',
maternal: '',
birthdate: '',
gender: 0,
hire_date: '',
department_id: null,
job_position_id: null,
email: '',
password: '',
password_confirmation: '',
address: {
country: '',
postal_code: '',
state: '',
municipality: '',
city: '',
street: '',
num_ext: '',
num_int: ''
}
});
// Fetch Employees from API
const fetchEmployees = async (showToast = true) => {
loading.value = true;
try {
const response = await employeesService.getEmployees({ paginate: false });
if ('data' in response) {
employees.value = response.data;
}
if (showToast) {
toast.add({
severity: 'success',
summary: 'Empleados Cargados',
detail: `Se cargaron ${employees.value.length} empleados`,
life: 3000
});
}
} catch (error) {
console.error('Error al cargar empleados:', error);
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudieron cargar los empleados',
life: 3000
});
} finally {
loading.value = false;
}
};
// Fetch Departments from API
const fetchDepartments = async () => {
try {
const response = await departmentsService.getDepartments();
departments.value = response.data.map(dept => ({
id: dept.id,
name: dept.name,
description: dept.description
}));
} catch (error) {
console.error('Error al cargar departamentos:', error);
}
};
// Fetch Positions from API
const fetchPositions = async () => {
try {
const response = await positionsService.getPositions();
positions.value = response.data.map(pos => ({
id: pos.id,
name: pos.name,
code: pos.code
}));
} catch (error) {
console.error('Error al cargar puestos:', error);
}
};
// Lifecycle
onMounted(() => {
fetchEmployees();
fetchDepartments();
fetchPositions();
});
// Helper Methods
const getFullName = (employee: Employee): string => {
return `${employee.name} ${employee.paternal} ${employee.maternal}`.trim();
};
const formatDate = (dateString: string | null): string => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleDateString('es-MX', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
// Actions
const openCreateDialog = () => {
dialogMode.value = 'create';
formData.value = {
name: '',
paternal: '',
maternal: '',
birthdate: '',
gender: 0,
hire_date: '',
department_id: null,
job_position_id: null,
email: '',
password: '',
password_confirmation: '',
address: {
country: 'México',
postal_code: '',
state: '',
municipality: '',
city: '',
street: '',
num_ext: '',
num_int: ''
}
};
showDialog.value = true;
};
const viewEmployee = (employee: Employee) => {
toast.add({
severity: 'info',
summary: 'Ver Empleado',
detail: `Visualizando: ${getFullName(employee)}`,
life: 3000
});
};
const editEmployee = (employee: Employee) => {
dialogMode.value = 'edit';
// Map EmployeeAddress to AddressDTO structure
const addressData = employee.addresses && employee.addresses.length > 0 && employee.addresses[0]
? {
country: employee.addresses[0].country,
postal_code: employee.addresses[0].postal_code,
state: employee.addresses[0].state,
municipality: employee.addresses[0].city, // Using city as municipality if no municipality field
city: employee.addresses[0].city,
street: employee.addresses[0].street,
num_ext: employee.addresses[0].num_ext,
num_int: employee.addresses[0].num_int
}
: {
country: 'México',
postal_code: '',
state: '',
municipality: '',
city: '',
street: '',
num_ext: '',
num_int: ''
};
formData.value = {
id: employee.id,
name: employee.name,
paternal: employee.paternal,
maternal: employee.maternal,
birthdate: employee.birthdate || '',
gender: employee.gender,
hire_date: employee.hire_date || '',
department_id: employee.department_id,
job_position_id: employee.job_position_id,
email: employee.user.email,
address: addressData
};
showDialog.value = true;
};
const confirmDelete = (employee: Employee) => {
employeeToDelete.value = employee;
showDeleteDialog.value = true;
};
const saveEmployee = async (data: CreateEmployeeDTO | UpdateEmployeeDTO) => {
try {
if (dialogMode.value === 'create') {
await employeesService.createEmployee(data as CreateEmployeeDTO);
toast.add({
severity: 'success',
summary: 'Empleado Creado',
detail: 'El empleado ha sido registrado exitosamente',
life: 3000
});
} else {
const updateData = data as UpdateEmployeeDTO;
await employeesService.updateEmployee(updateData.id, updateData);
toast.add({
severity: 'success',
summary: 'Empleado Actualizado',
detail: 'Los datos del empleado han sido actualizados',
life: 3000
});
}
await fetchEmployees(false);
showDialog.value = false;
} catch (error: any) {
console.error('Error al guardar empleado:', error);
if (error.response && error.response.status === 422 && error.response.data.errors) {
if (employeeFormRef.value) {
employeeFormRef.value.setErrors(error.response.data.errors);
}
toast.add({
severity: 'error',
summary: 'Error de Validación',
detail: 'Por favor revise los campos marcados en rojo',
life: 4000
});
} else {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudo guardar el empleado',
life: 3000
});
}
}
};
const deleteEmployee = async () => {
if (!employeeToDelete.value) return;
try {
await employeesService.deleteEmployee(employeeToDelete.value.id);
await fetchEmployees(false);
toast.add({
severity: 'success',
summary: 'Empleado Eliminado',
detail: `${getFullName(employeeToDelete.value)} ha sido eliminado exitosamente`,
life: 3000
});
} catch (error) {
console.error('Error al eliminar empleado:', error);
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudo eliminar el empleado',
life: 3000
});
} finally {
showDeleteDialog.value = false;
employeeToDelete.value = null;
}
};
</script>
<style scoped>
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,533 @@
<template>
<Dialog
:visible="visible"
@update:visible="$emit('update:visible', $event)"
:modal="true"
:style="{ width: '900px' }"
:closable="true"
class="p-0"
>
<template #header>
<div class="flex flex-col gap-1 w-full">
<h2 class="text-gray-900 dark:text-white text-2xl font-bold tracking-tight">
{{ mode === 'create' ? 'Registrar Nuevo Empleado' : 'Editar Empleado' }}
</h2>
<p class="text-gray-500 dark:text-gray-400 text-base font-normal leading-normal">
Complete la información del empleado para su registro en el sistema.
</p>
</div>
</template>
<div class="p-6 pt-0 space-y-6">
<!-- Información Personal -->
<div class="border-b pb-4">
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Información Personal</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Nombre(s) *
</label>
<InputText
v-model="localFormData.name"
placeholder="Ej. Juan"
class="w-full"
:class="{ 'p-invalid': errors.name }"
/>
<small v-if="errors.name" class="text-red-600">{{ errors.name }}</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Apellido Paterno *
</label>
<InputText
v-model="localFormData.paternal"
placeholder="Ej. Pérez"
class="w-full"
:class="{ 'p-invalid': errors.paternal }"
/>
<small v-if="errors.paternal" class="text-red-600">{{ errors.paternal }}</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Apellido Materno *
</label>
<InputText
v-model="localFormData.maternal"
placeholder="Ej. García"
class="w-full"
:class="{ 'p-invalid': errors.maternal }"
/>
<small v-if="errors.maternal" class="text-red-600">{{ errors.maternal }}</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Género *
</label>
<Dropdown
v-model="localFormData.gender"
:options="genderOptions"
optionLabel="label"
optionValue="value"
placeholder="Seleccionar género"
class="w-full"
:class="{ 'p-invalid': errors.gender }"
/>
<small v-if="errors.gender" class="text-red-600">{{ errors.gender }}</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Fecha de Nacimiento *
</label>
<Calendar
v-model="localFormData.birthdate"
dateFormat="yy-mm-dd"
placeholder="AAAA-MM-DD"
class="w-full"
:class="{ 'p-invalid': errors.birthdate }"
:maxDate="new Date()"
showIcon
/>
<small v-if="errors.birthdate" class="text-red-600">{{ errors.birthdate }}</small>
</div>
</div>
</div>
<!-- Información Laboral -->
<div class="border-b pb-4">
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Información Laboral</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Departamento
</label>
<Dropdown
v-model="localFormData.department_id"
:options="departmentOptions"
optionLabel="label"
optionValue="value"
placeholder="Seleccionar departamento"
class="w-full"
showClear
:class="{ 'p-invalid': errors.department_id }"
/>
<small v-if="errors.department_id" class="text-red-600">{{ errors.department_id }}</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Puesto
</label>
<Dropdown
v-model="localFormData.job_position_id"
:options="positionOptions"
optionLabel="label"
optionValue="value"
placeholder="Seleccionar puesto"
class="w-full"
showClear
:class="{ 'p-invalid': errors.job_position_id }"
/>
<small v-if="errors.job_position_id" class="text-red-600">{{ errors.job_position_id }}</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Fecha de Contratación *
</label>
<Calendar
v-model="localFormData.hire_date"
dateFormat="yy-mm-dd"
placeholder="AAAA-MM-DD"
class="w-full"
:class="{ 'p-invalid': errors.hire_date }"
showIcon
/>
<small v-if="errors.hire_date" class="text-red-600">{{ errors.hire_date }}</small>
</div>
</div>
</div>
<!-- Credenciales de Usuario -->
<div class="border-b pb-4">
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Credenciales de Acceso</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-2 md:col-span-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Correo Electrónico *
</label>
<InputText
v-model="localFormData.email"
type="email"
placeholder="ejemplo@empresa.com"
class="w-full"
:class="{ 'p-invalid': errors.email }"
/>
<small v-if="errors.email" class="text-red-600">{{ errors.email }}</small>
</div>
<div v-if="mode === 'create'" class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Contraseña *
</label>
<Password
v-model="localFormData.password"
placeholder="Mínimo 8 caracteres"
class="w-full"
:class="{ 'p-invalid': errors.password }"
toggleMask
:feedback="false"
/>
<small v-if="errors.password" class="text-red-600">{{ errors.password }}</small>
</div>
<div v-if="mode === 'create'" class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Confirmar Contraseña *
</label>
<Password
v-model="localFormData.password_confirmation"
placeholder="Repetir contraseña"
class="w-full"
:class="{ 'p-invalid': errors.password_confirmation }"
toggleMask
:feedback="false"
/>
<small v-if="errors.password_confirmation" class="text-red-600">{{ errors.password_confirmation }}</small>
</div>
</div>
</div>
<!-- Dirección -->
<div>
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Dirección</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Calle *
</label>
<InputText
v-model="localFormData.address.street"
placeholder="Ej. Av. Constitución"
class="w-full"
:class="{ 'p-invalid': errors['address.street'] }"
/>
<small v-if="errors['address.street']" class="text-red-600">{{ errors['address.street'] }}</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Número Exterior *
</label>
<InputText
v-model="localFormData.address.num_ext"
placeholder="Ej. 123"
class="w-full"
:class="{ 'p-invalid': errors['address.num_ext'] }"
/>
<small v-if="errors['address.num_ext']" class="text-red-600">{{ errors['address.num_ext'] }}</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Número Interior
</label>
<InputText
v-model="localFormData.address.num_int"
placeholder="Ej. 4A"
class="w-full"
/>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Código Postal *
</label>
<InputText
v-model="localFormData.address.postal_code"
placeholder="Ej. 64000"
class="w-full"
:class="{ 'p-invalid': errors['address.postal_code'] }"
/>
<small v-if="errors['address.postal_code']" class="text-red-600">{{ errors['address.postal_code'] }}</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Ciudad *
</label>
<InputText
v-model="localFormData.address.city"
placeholder="Ej. Monterrey"
class="w-full"
:class="{ 'p-invalid': errors['address.city'] }"
/>
<small v-if="errors['address.city']" class="text-red-600">{{ errors['address.city'] }}</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Municipio *
</label>
<InputText
v-model="localFormData.address.municipality"
placeholder="Ej. Monterrey"
class="w-full"
:class="{ 'p-invalid': errors['address.municipality'] }"
/>
<small v-if="errors['address.municipality']" class="text-red-600">{{ errors['address.municipality'] }}</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
Estado *
</label>
<InputText
v-model="localFormData.address.state"
placeholder="Ej. Nuevo León"
class="w-full"
:class="{ 'p-invalid': errors['address.state'] }"
/>
<small v-if="errors['address.state']" class="text-red-600">{{ errors['address.state'] }}</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-sm font-semibold">
País *
</label>
<InputText
v-model="localFormData.address.country"
placeholder="Ej. México"
class="w-full"
:class="{ 'p-invalid': errors['address.country'] }"
/>
<small v-if="errors['address.country']" class="text-red-600">{{ errors['address.country'] }}</small>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex items-center justify-end gap-4 pt-4 border-t border-gray-200 dark:border-gray-800">
<Button
label="Cancelar"
severity="secondary"
text
@click="handleCancel"
class="px-6"
/>
<Button
:label="mode === 'create' ? 'Registrar Empleado' : 'Actualizar Empleado'"
icon="pi pi-save"
@click="handleSave"
class="px-8"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import Dropdown from 'primevue/dropdown';
import Calendar from 'primevue/calendar';
import Password from 'primevue/password';
import type { CreateEmployeeDTO, UpdateEmployeeDTO, AddressDTO } from './employees.interfaces';
import type { DepartmentReference, JobPositionReference } from './employees.interfaces';
// Local form data type that always has address
interface LocalFormData {
id?: number;
name: string;
paternal: string;
maternal: string;
birthdate: Date | null;
gender: number;
hire_date: Date | null;
department_id: number | null;
job_position_id: number | null;
email: string;
password?: string;
password_confirmation?: string;
address: AddressDTO;
}
interface Props {
visible: boolean;
mode: 'create' | 'edit';
formData: CreateEmployeeDTO | UpdateEmployeeDTO;
departments?: DepartmentReference[];
positions?: JobPositionReference[];
}
interface Emits {
(e: 'update:visible', value: boolean): void;
(e: 'save', data: CreateEmployeeDTO | UpdateEmployeeDTO): void;
(e: 'cancel'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Convert string date to Date object
const parseDate = (dateString: string | undefined | null): Date | null => {
if (!dateString) return null;
const date = new Date(dateString);
return isNaN(date.getTime()) ? null : date;
};
// Initialize local form data with proper structure
const initializeFormData = (data: CreateEmployeeDTO | UpdateEmployeeDTO): LocalFormData => {
return {
id: 'id' in data ? data.id : undefined,
name: data.name || '',
paternal: data.paternal || '',
maternal: data.maternal || '',
birthdate: parseDate(data.birthdate),
gender: data.gender || 0,
hire_date: parseDate(data.hire_date),
department_id: data.department_id || null,
job_position_id: data.job_position_id || null,
email: data.email || '',
password: 'password' in data ? data.password : undefined,
password_confirmation: 'password_confirmation' in data ? data.password_confirmation : undefined,
address: data.address || {
country: '',
postal_code: '',
state: '',
municipality: '',
city: '',
street: '',
num_ext: '',
num_int: ''
}
};
};
// Local form data
const localFormData = ref<LocalFormData>(initializeFormData(props.formData));
// Validation errors
const errors = ref<Record<string, string>>({});
// Gender options
const genderOptions = [
{ label: 'Femenino', value: 0 },
{ label: 'Masculino', value: 1 }
];
// Department options
const departmentOptions = computed(() => {
return props.departments?.map(dept => ({
label: dept.name,
value: dept.id
})) || [];
});
// Position options
const positionOptions = computed(() => {
return props.positions?.map(pos => ({
label: pos.name,
value: pos.id
})) || [];
});
// Watch for formData changes
watch(() => props.formData, (newData) => {
localFormData.value = initializeFormData(newData);
errors.value = {};
}, { deep: true });
// Format date for API (Calendar works with Date objects)
const formatDateForAPI = (date: Date | null): string | null => {
if (!date || !(date instanceof Date)) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
// Handlers
const handleSave = () => {
errors.value = {};
// Prepare data for API
if (props.mode === 'create') {
const dataToSave: CreateEmployeeDTO = {
name: localFormData.value.name,
paternal: localFormData.value.paternal,
maternal: localFormData.value.maternal,
birthdate: formatDateForAPI(localFormData.value.birthdate) || '',
gender: localFormData.value.gender,
hire_date: formatDateForAPI(localFormData.value.hire_date) || '',
department_id: localFormData.value.department_id,
job_position_id: localFormData.value.job_position_id,
email: localFormData.value.email,
password: localFormData.value.password || '',
password_confirmation: localFormData.value.password_confirmation || '',
address: localFormData.value.address
};
emit('save', dataToSave);
} else {
const dataToSave: UpdateEmployeeDTO = {
id: localFormData.value.id!,
name: localFormData.value.name,
paternal: localFormData.value.paternal,
maternal: localFormData.value.maternal,
birthdate: formatDateForAPI(localFormData.value.birthdate) || '',
gender: localFormData.value.gender,
hire_date: formatDateForAPI(localFormData.value.hire_date) || '',
department_id: localFormData.value.department_id,
job_position_id: localFormData.value.job_position_id,
email: localFormData.value.email,
address: localFormData.value.address
};
emit('save', dataToSave);
}
};
const handleCancel = () => {
errors.value = {};
emit('cancel');
};
// Expose method to set errors from parent component
const setErrors = (validationErrors: Record<string, string[]>) => {
errors.value = {};
Object.keys(validationErrors).forEach((key) => {
const errorMessages = validationErrors[key];
if (errorMessages && errorMessages[0]) {
errors.value[key] = errorMessages[0];
}
});
};
defineExpose({
setErrors
});
</script>
<style scoped>
:deep(.p-password) {
width: 100%;
}
:deep(.p-password input) {
width: 100%;
}
:deep(.p-calendar) {
width: 100%;
}
:deep(.p-calendar input) {
width: 100%;
}
</style>

View File

@ -0,0 +1,144 @@
// User Interface
export interface User {
id: number;
email: string;
email_verified_at: string | null;
status: number;
created_at: string;
updated_at: string;
deleted_at: string | null;
full_name: string;
last_name: string;
profile_photo_url: string;
}
// Department Reference (simplified)
export interface DepartmentReference {
id: number;
name: string;
description?: string;
}
// Job Position Reference (simplified)
export interface JobPositionReference {
id: number;
name: string;
code: string;
}
// Address Interface
export interface EmployeeAddress {
id: number;
street: string;
num_ext: string;
num_int?: string;
postal_code: string;
city: string;
state: string;
country: string;
}
// Employee Interface
export interface Employee {
id: number;
name: string;
paternal: string;
maternal: string;
birthdate: string | null;
gender: number;
hire_date: string | null;
department_id: number | null;
job_position_id: number | null;
user_id: number;
created_at: string;
updated_at: string;
deleted_at: string | null;
user: User;
department: DepartmentReference | null;
job_position: JobPositionReference | null;
addresses: EmployeeAddress[];
}
// Pagination Link
export interface PaginationLink {
url: string | null;
label: string;
active: boolean;
}
// Paginated Response
export interface PaginatedEmployeesResponse {
current_page: number;
data: Employee[];
first_page_url: string;
from: number;
last_page: number;
last_page_url: string;
links: PaginationLink[];
next_page_url: string | null;
path: string;
per_page: number;
prev_page_url: string | null;
to: number;
total: number;
}
// Simple Response (non-paginated)
export interface SimpleEmployeesResponse {
data: Employee[];
}
// Query Parameters
export interface EmployeeQueryParams {
page?: number;
per_page?: number;
name?: string;
department_id?: number;
job_position_id?: number;
paginate?: boolean;
}
// Address DTO for Create/Update
export interface AddressDTO {
country: string;
postal_code: string;
state: string;
municipality: string;
city: string;
street: string;
num_ext: string;
num_int?: string;
}
// Create Employee DTO
export interface CreateEmployeeDTO {
name: string;
paternal: string;
maternal: string;
birthdate: string;
gender: number;
hire_date: string;
department_id: number | null;
job_position_id: number | null;
email: string;
password: string;
password_confirmation: string;
address: AddressDTO;
}
// Update Employee DTO
export interface UpdateEmployeeDTO {
id: number;
name: string;
paternal: string;
maternal: string;
birthdate: string;
gender: number;
hire_date: string;
department_id: number | null;
job_position_id: number | null;
email: string;
password?: string;
password_confirmation?: string;
address: AddressDTO;
}

View File

@ -0,0 +1,88 @@
import api from "@/services/api";
import type {
PaginatedEmployeesResponse,
SimpleEmployeesResponse,
EmployeeQueryParams,
CreateEmployeeDTO,
UpdateEmployeeDTO,
Employee
} from "./employees.interfaces";
export class EmployeesService {
/**
* Get all employees with optional pagination
* @param params - Query parameters for filtering and pagination
* @returns PaginatedEmployeesResponse or SimpleEmployeesResponse
*/
public async getEmployees(params?: EmployeeQueryParams): Promise<PaginatedEmployeesResponse | SimpleEmployeesResponse> {
try {
const response = await api.get('/api/rh/employees', { params });
return response.data;
} catch (error) {
console.error('Error fetching employees:', error);
throw error;
}
}
/**
* Create a new employee
* @param data - Employee data
* @returns Created employee
*/
public async createEmployee(data: CreateEmployeeDTO): Promise<Employee> {
try {
const response = await api.post('/api/rh/employees', data);
return response.data;
} catch (error) {
console.error('Error creating employee:', error);
throw error;
}
}
/**
* Update an existing employee
* @param id - Employee ID
* @param data - Updated employee data
* @returns Updated employee
*/
public async updateEmployee(id: number, data: UpdateEmployeeDTO): Promise<Employee> {
try {
const response = await api.put(`/api/rh/employees/${id}`, data);
return response.data;
} catch (error) {
console.error('Error updating employee:', error);
throw error;
}
}
/**
* Delete an employee
* @param id - Employee ID
*/
public async deleteEmployee(id: number): Promise<void> {
try {
await api.delete(`/api/rh/employees/${id}`);
} catch (error) {
console.error('Error deleting employee:', error);
throw error;
}
}
/**
* Get employee by ID
* @param id - Employee ID
* @returns Employee details
*/
public async getEmployeeById(id: number): Promise<Employee> {
try {
const response = await api.get(`/api/rh/employees/${id}`);
return response.data;
} catch (error) {
console.error('Error fetching employee:', error);
throw error;
}
}
}
export const employeesService = new EmployeesService();

View File

@ -37,10 +37,16 @@
</Column>
<Column field="name" header="Nombre del Puesto" sortable>
<template #body="slotProps">
<div class="flex items-center gap-3">
<div class="flex flex-col">
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ slotProps.data.name }}
</span>
<span v-if="slotProps.data.department" class="text-xs text-gray-500 dark:text-gray-400">
<i class="pi pi-building text-xs"></i> {{ slotProps.data.department.name }}
</span>
<span v-if="slotProps.data.parent" class="text-xs text-primary-600 dark:text-primary-400">
<i class="pi pi-arrow-up-right text-xs"></i> {{ slotProps.data.parent.name }}
</span>
</div>
</template>
</Column>
@ -68,8 +74,15 @@
</Card>
<!-- Create/Edit Dialog -->
<PositionsForm v-model:visible="showDialog" :mode="dialogMode" :formData="formData" @save="savePosition"
@cancel="showDialog = false" />
<PositionsForm
v-model:visible="showDialog"
:mode="dialogMode"
:formData="formData"
:departments="departments"
:positions="positions"
@save="savePosition"
@cancel="showDialog = false"
/>
<!-- Delete Confirmation Dialog -->
<Dialog v-model:visible="showDeleteDialog" header="Confirmar Eliminación" :modal="true"
@ -97,8 +110,10 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { PositionsService } from '../../services/positions.services';
import type { Position, CreatePositionDTO, UpdatePositionDTO } from '../../types/positions.interface';
import { PositionsService } from './positions.services';
import { DepartmentsService } from '../departments/departments.services';
import type { Position, CreatePositionDTO, UpdatePositionDTO } from './positions.interface';
import type { Department } from '../departments/departments.interface';
// PrimeVue Components
import Toast from 'primevue/toast';
@ -112,6 +127,7 @@ import PositionsForm from './PositionsForm.vue';
const toast = useToast();
const positionsService = new PositionsService();
const departmentsService = new DepartmentsService();
// State
const loading = ref(false);
@ -134,11 +150,14 @@ const breadcrumbItems = ref([
const formData = ref<CreatePositionDTO | UpdatePositionDTO>({
name: '',
code: '',
description: ''
description: '',
department_id: null,
parent_id: null
});
// Positions Data
const positions = ref<Position[]>([]);
const departments = ref<Department[]>([]);
// Computed
const filteredPositions = computed(() => {
@ -182,8 +201,25 @@ const fetchPositions = async (showToast = true) => {
}
};
// Fetch Departments from API
const fetchDepartments = async () => {
try {
const response = await departmentsService.getDepartments();
departments.value = response.data;
} catch (error) {
console.error('Error al cargar departamentos:', error);
toast.add({
severity: 'error',
summary: 'Error',
detail: 'No se pudieron cargar los departamentos',
life: 3000
});
}
};
// Lifecycle
onMounted(() => {
fetchDepartments();
fetchPositions();
});
@ -192,7 +228,9 @@ const openCreateDialog = () => {
formData.value = {
name: '',
code: '',
description: ''
description: '',
department_id: 0,
parent_id: null
};
showDialog.value = true;
};
@ -251,7 +289,17 @@ const savePosition = async (data: CreatePositionDTO | UpdatePositionDTO) => {
toast.add({
severity: 'warn',
summary: 'Validación',
detail: 'Nombre y departamento son requeridos',
detail: 'Nombre y código son requeridos',
life: 3000
});
return;
}
if (!data.department_id) {
toast.add({
severity: 'warn',
summary: 'Validación',
detail: 'El departamento es requerido',
life: 3000
});
return;
@ -263,7 +311,9 @@ const savePosition = async (data: CreatePositionDTO | UpdatePositionDTO) => {
const createData: CreatePositionDTO = {
name: data.name,
code: data.code,
description: data.description || null
description: data.description || null,
department_id: data.department_id,
parent_id: data.parent_id || null
};
await positionsService.createPosition(createData);
@ -290,7 +340,9 @@ const savePosition = async (data: CreatePositionDTO | UpdatePositionDTO) => {
const updateData: UpdatePositionDTO = {
name: updateDataWithId.name,
code: updateDataWithId.code,
description: updateDataWithId.description || null
description: updateDataWithId.description || null,
department_id: updateDataWithId.department_id,
parent_id: updateDataWithId.parent_id || null
};
await positionsService.updatePosition(updateDataWithId.id, updateData);

View File

@ -19,6 +19,43 @@
</template>
<div class="p-6 pt-0 space-y-6">
<!-- Departamento -->
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
Departamento *
</label>
<Dropdown
v-model="localFormData.department_id"
:options="departmentOptions"
optionLabel="label"
optionValue="value"
placeholder="Seleccionar departamento"
class="w-full h-14"
/>
<p class="text-gray-500 dark:text-gray-400 text-xs font-normal">
Departamento al que pertenece este puesto.
</p>
</div>
<!-- Puesto Padre (Opcional) -->
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
Puesto Padre (Opcional)
</label>
<Dropdown
v-model="localFormData.parent_id"
:options="positionOptions"
optionLabel="label"
optionValue="value"
placeholder="Seleccionar puesto superior"
class="w-full h-14"
showClear
/>
<p class="text-gray-500 dark:text-gray-400 text-xs font-normal">
Define la jerarquía organizacional. Déjelo vacío para puestos de nivel superior.
</p>
</div>
<div class="flex flex-col gap-2">
<label class="text-gray-900 dark:text-gray-200 text-base font-semibold leading-normal">
Nombre del Puesto
@ -87,17 +124,21 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { ref, watch, computed } from 'vue';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
import type { CreatePositionDTO, UpdatePositionDTO } from '../../types/positions.interface';
import Dropdown from 'primevue/dropdown';
import type { CreatePositionDTO, UpdatePositionDTO, Position } from './positions.interface';
import type { Department } from '../departments/departments.interface';
interface Props {
visible: boolean;
mode: 'create' | 'edit';
formData: CreatePositionDTO | UpdatePositionDTO;
departments?: Department[];
positions?: Position[];
}
interface Emits {
@ -111,6 +152,37 @@ const emit = defineEmits<Emits>();
const localFormData = ref<CreatePositionDTO | UpdatePositionDTO>({ ...props.formData });
// Computed: Opciones de departamentos para el dropdown
const departmentOptions = computed(() => {
if (!props.departments) return [];
return props.departments.map(dept => ({
label: dept.name,
value: dept.id
}));
});
// Computed: Opciones de puestos disponibles para seleccionar como padre
const availablePositions = computed(() => {
if (!props.positions) return [];
// Si estamos editando, excluir el puesto actual para evitar referencias circulares
const dataWithId = localFormData.value as UpdatePositionDTO & { id?: number };
if (props.mode === 'edit' && dataWithId.id) {
return props.positions.filter(pos => pos.id !== dataWithId.id);
}
return props.positions;
});
// Opciones para el dropdown de puestos (incluye opción "Sin puesto padre")
const positionOptions = computed(() => [
{ label: 'Sin puesto padre (Nivel raíz)', value: null },
...availablePositions.value.map(pos => ({
label: `${pos.name} (${pos.code})`,
value: pos.id
}))
]);
// Watch for external formData changes
watch(() => props.formData, (newData) => {
localFormData.value = { ...newData };

View File

@ -3,6 +3,13 @@ export interface Position {
name: string;
code: string;
description: string | null;
department_id: number | null;
parent_id?: number | null;
department?: {
id: number;
name: string;
};
parent?: Position;
is_active: number;
created_at: Date;
updated_at: Date;
@ -13,6 +20,8 @@ export interface CreatePositionDTO {
name: string;
code: string;
description: string | null;
department_id: number | null;
parent_id?: number | null;
}
export interface UpdatePositionDTO extends Partial<CreatePositionDTO> {}

View File

@ -1,5 +1,5 @@
import api from "@/services/api";
import type { CreatePositionDTO, ResponsePositionsDTO, UpdatePositionDTO } from "../types/positions.interface";
import type { CreatePositionDTO, ResponsePositionsDTO, UpdatePositionDTO } from "./positions.interface";
export class PositionsService {

View File

@ -33,6 +33,7 @@ import CreateRequisition from '../modules/requisitions/CreateRequisition.vue';
import ClassificationsComercial from '../modules/catalog/components/comercial-classification/ClassificationsComercial.vue';
import WarehouseOutInventory from '../modules/warehouse/components/WarehouseOutInventory.vue';
import companiesRouter from '@/modules/catalog/components/companies/companies.router';
import Employees from '../modules/rh/components/employees/Employees.vue';
const routes: RouteRecordRaw[] = [
{
@ -332,6 +333,15 @@ const routes: RouteRecordRaw[] = [
requiresAuth: true
}
},
{
path: 'employees',
name: 'Employees',
component: Employees,
meta: {
title: 'Gestión de Empleados',
requiresAuth: true
}
}
]
},
{