Merge pull request 'feat: add departments and employees management components' (#17) from feature-comercial-module-ts into qa
Reviewed-on: #17
This commit is contained in:
commit
0463191414
@ -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' }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
269
src/modules/rh/components/departments/DepartmentDetailModal.vue
Normal file
269
src/modules/rh/components/departments/DepartmentDetailModal.vue
Normal 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>
|
||||
@ -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 };
|
||||
|
||||
@ -47,9 +47,14 @@
|
||||
>
|
||||
<i :class="slotProps.data.icon"></i>
|
||||
</div>
|
||||
<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);
|
||||
|
||||
@ -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> {}
|
||||
@ -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);
|
||||
512
src/modules/rh/components/employees/Employees.vue
Normal file
512
src/modules/rh/components/employees/Employees.vue
Normal 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>
|
||||
533
src/modules/rh/components/employees/EmployeesForm.vue
Normal file
533
src/modules/rh/components/employees/EmployeesForm.vue
Normal 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>
|
||||
144
src/modules/rh/components/employees/employees.interfaces.ts
Normal file
144
src/modules/rh/components/employees/employees.interfaces.ts
Normal 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;
|
||||
}
|
||||
88
src/modules/rh/components/employees/employees.services.ts
Normal file
88
src/modules/rh/components/employees/employees.services.ts
Normal 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();
|
||||
@ -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);
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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> {}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user